diff --git a/fastapi_auth_api_key/README.rst b/fastapi_auth_api_key/README.rst new file mode 100644 index 00000000..f97f63c5 --- /dev/null +++ b/fastapi_auth_api_key/README.rst @@ -0,0 +1,130 @@ +==================== +Fastapi Auth Api Key +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:a1a8681b1c3e7a13dc83e2e61a1d78ad8c8da1ddb684c8cf563607e96cf4f7e7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/17.0/fastapi_auth_api_key + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-17-0/rest-framework-17-0-fastapi_auth_api_key + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provides FastAPI dependencies for Api Key authentication. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Getting an odoo environment +--------------------------- + +If you need to get an odoo env based on the provided api key, you can +use authenticated_env_by_auth_api_key. + +.. code:: python + + @router.get("/example_with_authenticated_env") + def example_with_authenticated_env( + env: Annotated[Environment, Depends(authenticated_env_by_auth_api_key)], + ) -> None: + # env.user is the user attached to the provided key + pass + +Getting the authenticated partner +--------------------------------- + +If want to get the partned related to the the provided api key, you can +use authenticated_partner_by_api_key + +.. code:: python + + @router.get("/example_with_authenticated_partner") + def example_with_authenticated_partner( + partner: Annotated[Partner, Depends(authenticated_partner_by_api_key)], + ) -> None: + # partner is the partner related to the provided key key.user_id.partner_id + pass + +Configuration +------------- + +For this to work, the api key must be defined on the Endpoint. A new +field auth_api_key_group_id has been added to the Endpoint model. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Matthieu Méquignon +- Son Ho + +Other credits +------------- + +The migration of this module from 16.0 to 17.0 was financially supported +by Camptocamp + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-mmequignon| image:: https://github.com/mmequignon.png?size=40px + :target: https://github.com/mmequignon + :alt: mmequignon + +Current `maintainer `__: + +|maintainer-mmequignon| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_auth_api_key/__init__.py b/fastapi_auth_api_key/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/fastapi_auth_api_key/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fastapi_auth_api_key/__manifest__.py b/fastapi_auth_api_key/__manifest__.py new file mode 100644 index 00000000..539efdcc --- /dev/null +++ b/fastapi_auth_api_key/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Fastapi Auth Api Key", + "version": "17.0.1.0.0", + "category": "Others", + "website": "https://github.com/OCA/rest-framework", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["mmequignon"], + "license": "AGPL-3", + "installable": True, + "depends": [ + "fastapi", + "auth_api_key_group", + ], + "data": [ + "views/fastapi_endpoint.xml", + ], +} diff --git a/fastapi_auth_api_key/dependencies.py b/fastapi_auth_api_key/dependencies.py new file mode 100644 index 00000000..aa1838ab --- /dev/null +++ b/fastapi_auth_api_key/dependencies.py @@ -0,0 +1,60 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from typing import Annotated + +from odoo import SUPERUSER_ID +from odoo.api import Environment +from odoo.exceptions import ValidationError + +from odoo.addons.auth_api_key.models.auth_api_key import AuthApiKey +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import fastapi_endpoint, odoo_env +from odoo.addons.fastapi.models.fastapi_endpoint import FastapiEndpoint + +from fastapi import Depends, status +from fastapi.exceptions import HTTPException +from fastapi.security import APIKeyHeader + + +def authenticated_auth_api_key( + key: Annotated[str, Depends(APIKeyHeader(name="HTTP-API-KEY"))], + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +) -> AuthApiKey: + if not key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="No HTTP-API-KEY provided", + headers={"WWW-Authenticate": "HTTP-API-KEY"}, + ) + admin_env = Environment(env.cr, SUPERUSER_ID, {}) + try: + auth_api_key = admin_env["auth.api.key"]._retrieve_api_key(key) + except ValidationError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=error.args, + headers={"WWW-Authenticate": "HTTP-API-KEY"}, + ) from error + # Ensure the api key is authorized for the current endpoint. + if auth_api_key not in endpoint.sudo().auth_api_key_group_id.auth_api_key_ids: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized", + headers={"WWW-Authenticate": "HTTP-API-KEY"}, + ) + return auth_api_key + + +def authenticated_partner_by_api_key( + auth_api_key: Annotated[AuthApiKey, Depends(authenticated_auth_api_key)] +) -> Partner: + return auth_api_key.user_id.partner_id + + +def authenticated_env_by_auth_api_key( + auth_api_key: Annotated[AuthApiKey, Depends(authenticated_auth_api_key)] +) -> Environment: + # set api key id in context + return auth_api_key.with_user(auth_api_key.user_id).env diff --git a/fastapi_auth_api_key/models/__init__.py b/fastapi_auth_api_key/models/__init__.py new file mode 100644 index 00000000..b825fab9 --- /dev/null +++ b/fastapi_auth_api_key/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint diff --git a/fastapi_auth_api_key/models/fastapi_endpoint.py b/fastapi_auth_api_key/models/fastapi_endpoint.py new file mode 100644 index 00000000..8da7ff66 --- /dev/null +++ b/fastapi_auth_api_key/models/fastapi_endpoint.py @@ -0,0 +1,10 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + auth_api_key_group_id = fields.Many2one("auth.api.key.group") diff --git a/fastapi_auth_api_key/pyproject.toml b/fastapi_auth_api_key/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/fastapi_auth_api_key/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_auth_api_key/readme/CONTRIBUTORS.md b/fastapi_auth_api_key/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..a6f2fed3 --- /dev/null +++ b/fastapi_auth_api_key/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Matthieu Méquignon \<\> +- Son Ho \<\> diff --git a/fastapi_auth_api_key/readme/CREDITS.md b/fastapi_auth_api_key/readme/CREDITS.md new file mode 100644 index 00000000..7c48f8e0 --- /dev/null +++ b/fastapi_auth_api_key/readme/CREDITS.md @@ -0,0 +1,2 @@ +The migration of this module from 16.0 to 17.0 was financially supported +by Camptocamp diff --git a/fastapi_auth_api_key/readme/DESCRIPTION.md b/fastapi_auth_api_key/readme/DESCRIPTION.md new file mode 100644 index 00000000..1e9aa691 --- /dev/null +++ b/fastapi_auth_api_key/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Provides FastAPI dependencies for Api Key authentication. diff --git a/fastapi_auth_api_key/readme/USAGE.md b/fastapi_auth_api_key/readme/USAGE.md new file mode 100644 index 00000000..f60799c4 --- /dev/null +++ b/fastapi_auth_api_key/readme/USAGE.md @@ -0,0 +1,32 @@ +## Getting an odoo environment + +If you need to get an odoo env based on the provided api key, you can +use authenticated_env_by_auth_api_key. + +``` python +@router.get("/example_with_authenticated_env") +def example_with_authenticated_env( + env: Annotated[Environment, Depends(authenticated_env_by_auth_api_key)], +) -> None: + # env.user is the user attached to the provided key + pass +``` + +## Getting the authenticated partner + +If want to get the partned related to the the provided api key, you can +use authenticated_partner_by_api_key + +``` python +@router.get("/example_with_authenticated_partner") +def example_with_authenticated_partner( + partner: Annotated[Partner, Depends(authenticated_partner_by_api_key)], +) -> None: + # partner is the partner related to the provided key key.user_id.partner_id + pass +``` + +## Configuration + +For this to work, the api key must be defined on the Endpoint. A new +field auth_api_key_group_id has been added to the Endpoint model. diff --git a/fastapi_auth_api_key/static/description/index.html b/fastapi_auth_api_key/static/description/index.html new file mode 100644 index 00000000..ec3643de --- /dev/null +++ b/fastapi_auth_api_key/static/description/index.html @@ -0,0 +1,369 @@ + + + + + + +README.rst + + + +
+ + + +
+ + diff --git a/fastapi_auth_api_key/tests/__init__.py b/fastapi_auth_api_key/tests/__init__.py new file mode 100644 index 00000000..da7258cc --- /dev/null +++ b/fastapi_auth_api_key/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_api_key_dependencies diff --git a/fastapi_auth_api_key/tests/test_fastapi_api_key_dependencies.py b/fastapi_auth_api_key/tests/test_fastapi_api_key_dependencies.py new file mode 100644 index 00000000..dde6088c --- /dev/null +++ b/fastapi_auth_api_key/tests/test_fastapi_api_key_dependencies.py @@ -0,0 +1,100 @@ +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from fastapi.exceptions import HTTPException + +from ..dependencies import ( + authenticated_auth_api_key, + authenticated_env_by_auth_api_key, + authenticated_partner_by_api_key, +) + + +@tagged("-at_install", "post_install") +class TestFastapiAuthApiKey(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + # Is it valid? We need an env without superpowers + demo_user = cls.env.ref("base.user_demo") + cls.demo_env = demo_user.with_user(demo_user).env + cls.demo_endpoint = cls.env["fastapi.endpoint"].create( + { + "name": "Test Enpoint", + "app": "demo", + "root_path": "/path_demo", + "demo_auth_method": "api_key", + "user_id": demo_user.id, + } + ) + cls.setUpClassApiKey() + + @classmethod + def setUpClassApiKey(cls): + user_model = cls.env["res.users"].with_context(no_reset_password=True) + cls.authorized_user = user_model.create( + { + "name": "John Authorized", + "login": "johnauth", + } + ) + cls.unauthorized_user = user_model.create( + { + "name": "Bob Unauthorized", + "login": "bobunauth", + } + ) + api_key_model = cls.env["auth.api.key"] + cls.authorized_api_key = api_key_model.create( + { + "user_id": cls.authorized_user.id, + "name": "Authorized api key", + "key": "authorized_key", + } + ) + cls.unauthorized_api_key = api_key_model.create( + { + "user_id": cls.unauthorized_user.id, + "name": "Unauthorized api key", + "key": "unauthorized_key", + } + ) + api_key_group_model = cls.env["auth.api.key.group"] + cls.authorized_api_key_group = api_key_group_model.create( + { + "name": "Authorized api key group", + "code": "authorized_api_key_group", + "auth_api_key_ids": [(6, 0, cls.authorized_api_key.ids)], + } + ) + + def test_authenticated_auth_api_key(self): + # An exception is raised when no api key is used + with self.assertRaises(HTTPException) as error: + authenticated_auth_api_key(False, self.demo_env, self.demo_endpoint) + self.assertEqual(error.exception.detail, "No HTTP-API-KEY provided") + # An exception is raised when no api key record is found + with self.assertRaises(HTTPException) as error: + authenticated_auth_api_key("404", self.demo_env, self.demo_endpoint) + self.assertEqual(error.exception.detail, ("The key 404 is not allowed",)) + # TODO enable this when we know how to filter keys based + # on endpoint's api key group. + # An exception is raised when unauthorized api key record is found + # with self.assertRaises(HTTPException) as error: + # authenticated_auth_api_key("not_authorized", self.demo_env) + self.demo_endpoint.auth_api_key_group_id = ( + self.authorized_api_key.auth_api_key_group_ids[0] + ) + result_key = authenticated_auth_api_key( + self.authorized_api_key.key, self.demo_env, self.demo_endpoint + ) + self.assertEqual(result_key, self.authorized_api_key) + + def test_authenticated_partner_by_api_key(self): + result_partner = authenticated_partner_by_api_key(self.authorized_api_key) + self.assertEqual(result_partner, self.authorized_user.partner_id) + + def test_authenticated_env_by_auth_api_key(self): + result_env = authenticated_env_by_auth_api_key(self.authorized_api_key) + self.assertEqual(result_env.user, self.authorized_user) diff --git a/fastapi_auth_api_key/views/fastapi_endpoint.xml b/fastapi_auth_api_key/views/fastapi_endpoint.xml new file mode 100644 index 00000000..1005dd50 --- /dev/null +++ b/fastapi_auth_api_key/views/fastapi_endpoint.xml @@ -0,0 +1,17 @@ + + + + + + fastapi.endpoint.form.inherit + fastapi.endpoint + + + + + + + + + diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9cd16292..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# generated from manifests external_dependencies diff --git a/setup/fastapi_auth_api_key/odoo/addons/fastapi_auth_api_key b/setup/fastapi_auth_api_key/odoo/addons/fastapi_auth_api_key new file mode 120000 index 00000000..498f5ddb --- /dev/null +++ b/setup/fastapi_auth_api_key/odoo/addons/fastapi_auth_api_key @@ -0,0 +1 @@ +../../../../fastapi_auth_api_key \ No newline at end of file diff --git a/setup/fastapi_auth_api_key/setup.py b/setup/fastapi_auth_api_key/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/fastapi_auth_api_key/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt index f7621d17..cd159839 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,3 @@ httpx +odoo-addon-fastapi @ git+https://github.com/OCA/rest-framework.git@refs/pull/409/head#subdirectory=fastapi +odoo-addon-auth_api_key_group @ git+https://github.com/OCA/server-auth.git@refs/pull/654/head#subdirectory=auth_api_key_group