From 8ab7167eaf046fb1c7a700dd72e773bb16e7d88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Fri, 28 Nov 2025 07:55:15 -0800 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=92=85=20Update=20CSS=20to=20explic?= =?UTF-8?q?itly=20use=20emoji=20font=20(#14415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/css/custom.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/css/custom.css b/docs/en/docs/css/custom.css index 8849d8741..87111ff64 100644 --- a/docs/en/docs/css/custom.css +++ b/docs/en/docs/css/custom.css @@ -1,9 +1,16 @@ /* Fira Code, including characters used by Rich output, like the "heavy right-pointing angle bracket ornament", not included in Google Fonts */ @import url(https://cdn.jsdelivr.net/npm/firacode@6.2.0/distr/fira_code.css); +/* Noto Color Emoji for emoji support with the same font everywhere */ +@import url(https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap); /* Override default code font in Material for MkDocs to Fira Code */ :root { - --md-code-font: "Fira Code", monospace; + --md-code-font: "Fira Code", monospace, "Noto Color Emoji"; +} + +/* Override default regular font in Material for MkDocs to include Noto Color Emoji */ +:root { + --md-text-font: "Roboto", "Noto Color Emoji"; } .termynal-comment { From 998288261af114efd39fb2061ed7ceba32f8699f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Nov 2025 15:55:40 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 2c50bc9f2..4b2cb937d 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Docs + +* ๐Ÿ’… Update CSS to explicitly use emoji font. PR [#14415](https://github.com/fastapi/fastapi/pull/14415) by [@tiangolo](https://github.com/tiangolo). + ## 0.122.0 ### Fixes From 62a69740041726b8c27815f6246272db5ebf7ee5 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:08:57 +0100 Subject: [PATCH 03/10] =?UTF-8?q?=E2=AC=86=20Bump=20markdown-include-varia?= =?UTF-8?q?nts=20from=200.0.5=20to=200.0.6=20(#14418)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements-docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 05b47fe92..ae1ddbc3d 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -17,5 +17,5 @@ griffe-warnings-deprecated==1.1.0 # For griffe, it formats with black black==25.1.0 mkdocs-macros-plugin==1.4.1 -markdown-include-variants==0.0.5 +markdown-include-variants==0.0.6 python-slugify==8.0.4 From c6487ed632056e450d844846a1b63be551a3cbc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 29 Nov 2025 12:09:26 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 4b2cb937d..fa8ddcdad 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -11,6 +11,10 @@ hide: * ๐Ÿ’… Update CSS to explicitly use emoji font. PR [#14415](https://github.com/fastapi/fastapi/pull/14415) by [@tiangolo](https://github.com/tiangolo). +### Internal + +* โฌ† Bump markdown-include-variants from 0.0.5 to 0.0.6. PR [#14418](https://github.com/fastapi/fastapi/pull/14418) by [@YuriiMotov](https://github.com/YuriiMotov). + ## 0.122.0 ### Fixes From 378ad688b7e57efb190506b3b36be65eb8ad5e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristj=C3=A1n=20Valur=20J=C3=B3nsson?= Date: Sun, 30 Nov 2025 11:57:01 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=9B=20Fix=20hierarchical=20secur?= =?UTF-8?q?ity=20scope=20propagation=20(#5624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastiรกn Ramรญrez Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Co-authored-by: svlandeg Co-authored-by: Sofie Van Landeghem --- fastapi/dependencies/utils.py | 4 +- tests/test_security_scopes_dont_propagate.py | 45 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/test_security_scopes_dont_propagate.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 1e92c1ba2..45353835b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -278,7 +278,9 @@ def get_dependant( use_security_scopes = security_scopes or [] if isinstance(param_details.depends, params.Security): if param_details.depends.scopes: - use_security_scopes.extend(param_details.depends.scopes) + use_security_scopes = use_security_scopes + list( + param_details.depends.scopes + ) sub_dependant = get_dependant( path=path, call=param_details.depends.dependency, diff --git a/tests/test_security_scopes_dont_propagate.py b/tests/test_security_scopes_dont_propagate.py new file mode 100644 index 000000000..2bbcc749d --- /dev/null +++ b/tests/test_security_scopes_dont_propagate.py @@ -0,0 +1,45 @@ +# Ref: https://github.com/tiangolo/fastapi/issues/5623 + +from typing import Any, Dict, List + +from fastapi import FastAPI, Security +from fastapi.security import SecurityScopes +from fastapi.testclient import TestClient +from typing_extensions import Annotated + + +async def security1(scopes: SecurityScopes): + return scopes.scopes + + +async def security2(scopes: SecurityScopes): + return scopes.scopes + + +async def dep3( + dep1: Annotated[List[str], Security(security1, scopes=["scope1"])], + dep2: Annotated[List[str], Security(security2, scopes=["scope2"])], +): + return {"dep1": dep1, "dep2": dep2} + + +app = FastAPI() + + +@app.get("/scopes") +def get_scopes( + dep3: Annotated[Dict[str, Any], Security(dep3, scopes=["scope3"])], +): + return dep3 + + +client = TestClient(app) + + +def test_security_scopes_dont_propagate(): + response = client.get("/scopes") + assert response.status_code == 200 + assert response.json() == { + "dep1": ["scope3", "scope1"], + "dep2": ["scope3", "scope2"], + } From 7681f2904d2f902057e357c107adf39ecfb14ea9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 30 Nov 2025 11:57:24 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index fa8ddcdad..65306828d 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* ๐Ÿ› Fix hierarchical security scope propagation. PR [#5624](https://github.com/fastapi/fastapi/pull/5624) by [@kristjanvalur](https://github.com/kristjanvalur). + ### Docs * ๐Ÿ’… Update CSS to explicitly use emoji font. PR [#14415](https://github.com/fastapi/fastapi/pull/14415) by [@tiangolo](https://github.com/tiangolo). From 63d7a2b9978258d13dfc22664e60fc2110d30e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 30 Nov 2025 13:00:20 +0100 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.122.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 65306828d..975166a63 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.122.1 + ### Fixes * ๐Ÿ› Fix hierarchical security scope propagation. PR [#5624](https://github.com/fastapi/fastapi/pull/5624) by [@kristjanvalur](https://github.com/kristjanvalur). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 3fbd7fc28..92c067e50 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.122.0" +__version__ = "0.122.1" from starlette import status as status From 7fbd30460f480e90faf321b9158bffb5116000d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 30 Nov 2025 06:45:49 -0800 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20Cache=20dependencies=20tha?= =?UTF-8?q?t=20don't=20use=20scopes=20and=20don't=20have=20sub-dependencie?= =?UTF-8?q?s=20with=20scopes=20(#14419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] --- fastapi/dependencies/models.py | 28 ++++- fastapi/dependencies/utils.py | 34 +++--- tests/test_security_scopes.py | 46 ++++++++ tests/test_security_scopes_sub_dependency.py | 107 +++++++++++++++++++ 4 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 tests/test_security_scopes.py create mode 100644 tests/test_security_scopes_sub_dependency.py diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index d6359c0f5..fbb666a7d 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -38,19 +38,43 @@ class Dependant: response_param_name: Optional[str] = None background_tasks_param_name: Optional[str] = None security_scopes_param_name: Optional[str] = None - security_scopes: Optional[List[str]] = None + own_oauth_scopes: Optional[List[str]] = None + parent_oauth_scopes: Optional[List[str]] = None use_cache: bool = True path: Optional[str] = None scope: Union[Literal["function", "request"], None] = None + @cached_property + def oauth_scopes(self) -> List[str]: + scopes = self.parent_oauth_scopes.copy() if self.parent_oauth_scopes else [] + # This doesn't use a set to preserve order, just in case + for scope in self.own_oauth_scopes or []: + if scope not in scopes: + scopes.append(scope) + return scopes + @cached_property def cache_key(self) -> DependencyCacheKey: + scopes_for_cache = ( + tuple(sorted(set(self.oauth_scopes or []))) if self._uses_scopes else () + ) return ( self.call, - tuple(sorted(set(self.security_scopes or []))), + scopes_for_cache, self.computed_scope or "", ) + @cached_property + def _uses_scopes(self) -> bool: + if self.own_oauth_scopes: + return True + if self.security_scopes_param_name is not None: + return True + for sub_dep in self.dependencies: + if sub_dep._uses_scopes: + return True + return False + @cached_property def is_gen_callable(self) -> bool: if inspect.isgeneratorfunction(self.call): diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 45353835b..d43fa8a51 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -58,8 +58,7 @@ from fastapi.dependencies.models import Dependant, SecurityRequirement from fastapi.exceptions import DependencyScopeError from fastapi.logger import logger from fastapi.security.base import SecurityBase -from fastapi.security.oauth2 import OAuth2, SecurityScopes -from fastapi.security.open_id_connect_url import OpenIdConnect +from fastapi.security.oauth2 import SecurityScopes from fastapi.types import DependencyCacheKey from fastapi.utils import create_model_field, get_path_param_names from pydantic import BaseModel @@ -126,14 +125,14 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De assert callable(depends.dependency), ( "A parameter-less dependency must have a callable dependency" ) - use_security_scopes: List[str] = [] + own_oauth_scopes: List[str] = [] if isinstance(depends, params.Security) and depends.scopes: - use_security_scopes.extend(depends.scopes) + own_oauth_scopes.extend(depends.scopes) return get_dependant( path=path, call=depends.dependency, scope=depends.scope, - security_scopes=use_security_scopes, + own_oauth_scopes=own_oauth_scopes, ) @@ -232,7 +231,8 @@ def get_dependant( path: str, call: Callable[..., Any], name: Optional[str] = None, - security_scopes: Optional[List[str]] = None, + own_oauth_scopes: Optional[List[str]] = None, + parent_oauth_scopes: Optional[List[str]] = None, use_cache: bool = True, scope: Union[Literal["function", "request"], None] = None, ) -> Dependant: @@ -240,19 +240,18 @@ def get_dependant( call=call, name=name, path=path, - security_scopes=security_scopes, use_cache=use_cache, scope=scope, + own_oauth_scopes=own_oauth_scopes, + parent_oauth_scopes=parent_oauth_scopes, ) + current_scopes = (parent_oauth_scopes or []) + (own_oauth_scopes or []) path_param_names = get_path_param_names(path) endpoint_signature = get_typed_signature(call) signature_params = endpoint_signature.parameters if isinstance(call, SecurityBase): - use_scopes: List[str] = [] - if isinstance(call, (OAuth2, OpenIdConnect)): - use_scopes = security_scopes or use_scopes security_requirement = SecurityRequirement( - security_scheme=call, scopes=use_scopes + security_scheme=call, scopes=current_scopes ) dependant.security_requirements.append(security_requirement) for param_name, param in signature_params.items(): @@ -275,17 +274,16 @@ def get_dependant( f'The dependency "{dependant.call.__name__}" has a scope of ' '"request", it cannot depend on dependencies with scope "function".' ) - use_security_scopes = security_scopes or [] + sub_own_oauth_scopes: List[str] = [] if isinstance(param_details.depends, params.Security): if param_details.depends.scopes: - use_security_scopes = use_security_scopes + list( - param_details.depends.scopes - ) + sub_own_oauth_scopes = list(param_details.depends.scopes) sub_dependant = get_dependant( path=path, call=param_details.depends.dependency, name=param_name, - security_scopes=use_security_scopes, + own_oauth_scopes=sub_own_oauth_scopes, + parent_oauth_scopes=current_scopes, use_cache=param_details.depends.use_cache, scope=param_details.depends.scope, ) @@ -611,7 +609,7 @@ async def solve_dependencies( path=use_path, call=call, name=sub_dependant.name, - security_scopes=sub_dependant.security_scopes, + parent_oauth_scopes=sub_dependant.oauth_scopes, scope=sub_dependant.scope, ) @@ -693,7 +691,7 @@ async def solve_dependencies( values[dependant.response_param_name] = response if dependant.security_scopes_param_name: values[dependant.security_scopes_param_name] = SecurityScopes( - scopes=dependant.security_scopes + scopes=dependant.oauth_scopes ) return SolvedDependency( values=values, diff --git a/tests/test_security_scopes.py b/tests/test_security_scopes.py new file mode 100644 index 000000000..248fd2bcc --- /dev/null +++ b/tests/test_security_scopes.py @@ -0,0 +1,46 @@ +from typing import Dict + +import pytest +from fastapi import Depends, FastAPI, Security +from fastapi.testclient import TestClient +from typing_extensions import Annotated + + +@pytest.fixture(name="call_counter") +def call_counter_fixture(): + return {"count": 0} + + +@pytest.fixture(name="app") +def app_fixture(call_counter: Dict[str, int]): + def get_db(): + call_counter["count"] += 1 + return f"db_{call_counter['count']}" + + def get_user(db: Annotated[str, Depends(get_db)]): + return "user" + + app = FastAPI() + + @app.get("/") + def endpoint( + db: Annotated[str, Depends(get_db)], + user: Annotated[str, Security(get_user, scopes=["read"])], + ): + return {"db": db} + + return app + + +@pytest.fixture(name="client") +def client_fixture(app: FastAPI): + return TestClient(app) + + +def test_security_scopes_dependency_called_once( + client: TestClient, call_counter: Dict[str, int] +): + response = client.get("/") + + assert response.status_code == 200 + assert call_counter["count"] == 1 diff --git a/tests/test_security_scopes_sub_dependency.py b/tests/test_security_scopes_sub_dependency.py new file mode 100644 index 000000000..9cc668d8e --- /dev/null +++ b/tests/test_security_scopes_sub_dependency.py @@ -0,0 +1,107 @@ +# Ref: https://github.com/fastapi/fastapi/discussions/6024#discussioncomment-8541913 + +from typing import Dict + +import pytest +from fastapi import Depends, FastAPI, Security +from fastapi.security import SecurityScopes +from fastapi.testclient import TestClient +from typing_extensions import Annotated + + +@pytest.fixture(name="call_counts") +def call_counts_fixture(): + return { + "get_db_session": 0, + "get_current_user": 0, + "get_user_me": 0, + "get_user_items": 0, + } + + +@pytest.fixture(name="app") +def app_fixture(call_counts: Dict[str, int]): + def get_db_session(): + call_counts["get_db_session"] += 1 + return f"db_session_{call_counts['get_db_session']}" + + def get_current_user( + security_scopes: SecurityScopes, + db_session: Annotated[str, Depends(get_db_session)], + ): + call_counts["get_current_user"] += 1 + return { + "user": f"user_{call_counts['get_current_user']}", + "scopes": security_scopes.scopes, + "db_session": db_session, + } + + def get_user_me( + current_user: Annotated[dict, Security(get_current_user, scopes=["me"])], + ): + call_counts["get_user_me"] += 1 + return { + "user_me": f"user_me_{call_counts['get_user_me']}", + "current_user": current_user, + } + + def get_user_items( + user_me: Annotated[dict, Depends(get_user_me)], + ): + call_counts["get_user_items"] += 1 + return { + "user_items": f"user_items_{call_counts['get_user_items']}", + "user_me": user_me, + } + + app = FastAPI() + + @app.get("/") + def path_operation( + user_me: Annotated[dict, Depends(get_user_me)], + user_items: Annotated[dict, Security(get_user_items, scopes=["items"])], + ): + return { + "user_me": user_me, + "user_items": user_items, + } + + return app + + +@pytest.fixture(name="client") +def client_fixture(app: FastAPI): + return TestClient(app) + + +def test_security_scopes_sub_dependency_caching( + client: TestClient, call_counts: Dict[str, int] +): + response = client.get("/") + + assert response.status_code == 200 + assert call_counts["get_db_session"] == 1 + assert call_counts["get_current_user"] == 2 + assert call_counts["get_user_me"] == 2 + assert call_counts["get_user_items"] == 1 + assert response.json() == { + "user_me": { + "user_me": "user_me_1", + "current_user": { + "user": "user_1", + "scopes": ["me"], + "db_session": "db_session_1", + }, + }, + "user_items": { + "user_items": "user_items_1", + "user_me": { + "user_me": "user_me_2", + "current_user": { + "user": "user_2", + "scopes": ["items", "me"], + "db_session": "db_session_1", + }, + }, + }, + } From c38e3e0108852f0dfec0e9bb5fec7b3ccf7ddad3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 30 Nov 2025 14:46:13 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 975166a63..505ae48a5 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* ๐Ÿ› Cache dependencies that don't use scopes and don't have sub-dependencies with scopes. PR [#14419](https://github.com/fastapi/fastapi/pull/14419) by [@tiangolo](https://github.com/tiangolo). + ## 0.122.1 ### Fixes From f2bab952678f301349c9805dd576af9425a95953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 30 Nov 2025 15:47:35 +0100 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.123.?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 505ae48a5..8848784b1 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.123.0 + ### Fixes * ๐Ÿ› Cache dependencies that don't use scopes and don't have sub-dependencies with scopes. PR [#14419](https://github.com/fastapi/fastapi/pull/14419) by [@tiangolo](https://github.com/tiangolo). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 92c067e50..25ed2bbeb 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.122.1" +__version__ = "0.123.0" from starlette import status as status