From 195a8eee7b6879fe52b3cdcb50f3fa8c4ab06548 Mon Sep 17 00:00:00 2001 From: Quentin Lhoest <42851186+lhoestq@users.noreply.github.com> Date: Tue, 24 Sep 2024 11:46:54 +0200 Subject: [PATCH] fine grained token for admin (#3067) * fine grained token for admin * simpler * style * fix tests --- services/admin/README.md | 2 +- services/admin/src/admin/authentication.py | 19 +++++++++++++++++-- services/admin/tests/test_authentication.py | 4 ++-- services/admin/tests/utils.py | 2 +- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/services/admin/README.md b/services/admin/README.md index 0f9ddf3d6b..ae74e64ae9 100644 --- a/services/admin/README.md +++ b/services/admin/README.md @@ -10,7 +10,7 @@ The service can be configured using environment variables. They are grouped by s Set environment variables to configure the application (`ADMIN_` prefix): -- `ADMIN_HF_ORGANIZATION`: the huggingface organization from which the authenticated user must be part of in order to access the protected routes, eg. "huggingface". If empty, the authentication is disabled. Defaults to None. +- `ADMIN_HF_ORGANIZATION`: the huggingface organization from which the authenticated user must be part of in order to access the protected routes, eg. "huggingface". If empty, the authentication is disabled. Defaults to None. Authentication requires a fine-grained token with `repo.write` permission. - `ADMIN_CACHE_REPORTS_NUM_RESULTS`: the number of results in /cache-reports/... endpoints. Defaults to `100`. - `ADMIN_CACHE_REPORTS_WITH_CONTENT_NUM_RESULTS`: the number of results in /cache-reports-with-content/... endpoints. Defaults to `100`. - `ADMIN_HF_TIMEOUT_SECONDS`: the timeout in seconds for the requests to the Hugging Face Hub. Defaults to `0.2` (200 ms). diff --git a/services/admin/src/admin/authentication.py b/services/admin/src/admin/authentication.py index 4ee6daebaa..ec8ecd8db5 100644 --- a/services/admin/src/admin/authentication.py +++ b/services/admin/src/admin/authentication.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2022 The HuggingFace Authors. +from collections.abc import Sequence from typing import Literal, Optional import httpx @@ -14,6 +15,7 @@ async def auth_check( request: Optional[Request] = None, organization: Optional[str] = None, hf_timeout_seconds: Optional[float] = None, + require_fine_grained_permissions: Sequence[str] = ("repo.write",), ) -> Literal[True]: """check if the user is member of the organization @@ -25,6 +27,8 @@ async def auth_check( authorized. hf_timeout_seconds (`float`, *optional*): the timeout in seconds for the HTTP request to the external authentication service. + require_fine_grained_permissions (`Sequence[str]`): require a fine-grained token with certain permissions + for the organization, if organization is provided. Defaults to ("repo.write",). Returns: `Literal[True]`: the user is authorized @@ -39,10 +43,21 @@ async def auth_check( if response.status_code == 200: try: json = response.json() - if organization is None or organization in {org["name"] for org in json["orgs"]}: + if organization is None or ( + organization in {org["name"] for org in json["orgs"]} + and json["auth"]["type"] == "access_token" + and "fineGrained" in json["auth"]["accessToken"] + and any( + set(permission for permission in scope["permissions"]) >= set(require_fine_grained_permissions) + for scope in json["auth"]["accessToken"]["fineGrained"]["scoped"] + if scope["entity"]["name"] == organization and scope["entity"]["type"] == "org" + ) + ): return True else: - raise ExternalAuthenticatedError("You are not member of the organization") + raise ExternalAuthenticatedError( + "Cannot access the route with the current credentials. Please retry with other authentication credentials." + ) except Exception as err: raise ExternalAuthenticatedError( "Cannot access the route with the current credentials. Please retry with other authentication" diff --git a/services/admin/tests/test_authentication.py b/services/admin/tests/test_authentication.py index fbbedb7135..1bd5e01eb1 100644 --- a/services/admin/tests/test_authentication.py +++ b/services/admin/tests/test_authentication.py @@ -40,7 +40,7 @@ async def test_external_auth_responses_without_request( status: int, error: Optional[type[Exception]], httpx_mock: HTTPXMock ) -> None: url = "https://auth.check" - body = '{"orgs": [{"name": "org1"}]}' + body = '{"orgs": [{"name": "org1"}], "auth": {"type": "access_token", "accessToken": {"fineGrained": {"scoped": [{"entity": {"type": "org", "name": "org1"}, "permissions": ["repo.write"]}]}}}}' httpx_mock.add_response(method="GET", url=url, status_code=status, text=body) if error is None: assert await auth_check(external_auth_url=url, organization="org1") @@ -55,7 +55,7 @@ async def test_external_auth_responses_without_request( ) async def test_org(org: str, status: int, error: Optional[type[Exception]], httpx_mock: HTTPXMock) -> None: url = "https://auth.check" - body = '{"orgs": [{"name": "org1"}]}' + body = '{"orgs": [{"name": "org1"}], "auth": {"type": "access_token", "accessToken": {"fineGrained": {"scoped": [{"entity": {"type": "org", "name": "org1"}, "permissions": ["repo.write"]}]}}}}' httpx_mock.add_response(method="GET", url=url, status_code=status, text=body) if error is None: assert await auth_check(external_auth_url=url, organization=org) diff --git a/services/admin/tests/utils.py b/services/admin/tests/utils.py index f0ec1be583..77ac701a79 100644 --- a/services/admin/tests/utils.py +++ b/services/admin/tests/utils.py @@ -9,7 +9,7 @@ def request_callback(request: httpx.Request) -> httpx.Response: # and 200 if none has been provided # there is no logic behind this behavior, it's just to test if the # tokens are correctly passed to the auth_check service - body = '{"orgs": [{"name": "org1"}]}' + body = '{"orgs": [{"name": "org1"}], "auth": {"type": "access_token", "accessToken": {"fineGrained": {"scoped": [{"entity": {"type": "org", "name": "org1"}, "permissions": ["repo.write"]}]}}}}' if request.headers.get("authorization"): return httpx.Response(status_code=404, text=body) return httpx.Response(status_code=200, text=body)