From 18f12eeac4867ffdc43c982f8f119b8121c407b4 Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 12 Dec 2024 18:15:13 +0100 Subject: [PATCH] [ADD] cross_connect_server --- .pre-commit-config.yaml | 2 +- cross_connect_server/README.rst | 110 +++++ cross_connect_server/__init__.py | 1 + cross_connect_server/__manifest__.py | 25 + cross_connect_server/dependencies.py | 9 + cross_connect_server/models/__init__.py | 3 + .../models/cross_connect_client.py | 117 +++++ .../models/fastapi_endpoint.py | 104 ++++ cross_connect_server/models/res_users.py | 19 + cross_connect_server/readme/CONTRIBUTORS.md | 1 + cross_connect_server/readme/DESCRIPTION.md | 2 + cross_connect_server/readme/USAGE.md | 17 + cross_connect_server/routers/__init__.py | 1 + cross_connect_server/routers/cross_connect.py | 78 +++ cross_connect_server/schemas.py | 46 ++ .../security/ir_model_access.xml | 17 + cross_connect_server/security/res_groups.xml | 15 + .../static/description/index.html | 448 ++++++++++++++++++ cross_connect_server/tests/__init__.py | 1 + .../tests/test_cross_connect_server.py | 363 ++++++++++++++ .../views/fastapi_endpoint_views.xml | 46 ++ .../odoo/addons/cross_connect_server | 1 + setup/cross_connect_server/setup.py | 6 + test-requirements.txt | 1 + 24 files changed, 1432 insertions(+), 1 deletion(-) create mode 100644 cross_connect_server/README.rst create mode 100644 cross_connect_server/__init__.py create mode 100644 cross_connect_server/__manifest__.py create mode 100644 cross_connect_server/dependencies.py create mode 100644 cross_connect_server/models/__init__.py create mode 100644 cross_connect_server/models/cross_connect_client.py create mode 100644 cross_connect_server/models/fastapi_endpoint.py create mode 100644 cross_connect_server/models/res_users.py create mode 100644 cross_connect_server/readme/CONTRIBUTORS.md create mode 100644 cross_connect_server/readme/DESCRIPTION.md create mode 100644 cross_connect_server/readme/USAGE.md create mode 100644 cross_connect_server/routers/__init__.py create mode 100644 cross_connect_server/routers/cross_connect.py create mode 100644 cross_connect_server/schemas.py create mode 100644 cross_connect_server/security/ir_model_access.xml create mode 100644 cross_connect_server/security/res_groups.xml create mode 100644 cross_connect_server/static/description/index.html create mode 100644 cross_connect_server/tests/__init__.py create mode 100644 cross_connect_server/tests/test_cross_connect_server.py create mode 100644 cross_connect_server/views/fastapi_endpoint_views.xml create mode 120000 setup/cross_connect_server/odoo/addons/cross_connect_server create mode 100644 setup/cross_connect_server/setup.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf39cc7c9..c8fd5e0dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -138,7 +138,7 @@ repos: - --header - "# generated from manifests external_dependencies" - repo: https://github.com/PyCQA/flake8 - rev: 3.8.3 + rev: 4.0.1 hooks: - id: flake8 name: flake8 diff --git a/cross_connect_server/README.rst b/cross_connect_server/README.rst new file mode 100644 index 000000000..c274ff7a1 --- /dev/null +++ b/cross_connect_server/README.rst @@ -0,0 +1,110 @@ +==================== +Cross Connect Server +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e7f2983ebb91caf2611da85b500923b3a91de86fbb4577c967a2a30e0ce7e739 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/16.0/cross_connect_server + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-cross_connect_server + :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/server-auth&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows other odoo instances, where the +``cross_connect_client`` module is installed and configured, users to +connect directly on this odoo instance. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +First of all after installing the module, you need to configure a +fastapi endpoint. + +In order to do that, you need to go to the menu +``FastAPI > FastAPI Endpoints`` and create a new endpoint for the client +to connect to. + +Fill the fields with the endpoint's information : + +- App: ``cross_connect`` +- Cross Connect Allowed Groups: The groups that will be allowed to be + selected for the clients groups. + +Then for each client, you will have to add an entry in the +``Cross Connect Clients`` table. + +An api key will be automatically generated for each client, this is the +key that you will have to provide to the client in order for them to +connect to the server. You will also have to choose the groups that this +client will be able to give to its users. + +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 +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +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-paradoxxxzero| image:: https://github.com/paradoxxxzero.png?size=40px + :target: https://github.com/paradoxxxzero + :alt: paradoxxxzero + +Current `maintainer `__: + +|maintainer-paradoxxxzero| + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/cross_connect_server/__init__.py b/cross_connect_server/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/cross_connect_server/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/cross_connect_server/__manifest__.py b/cross_connect_server/__manifest__.py new file mode 100644 index 000000000..57e066de6 --- /dev/null +++ b/cross_connect_server/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Cross Connect Server", + "version": "16.0.1.0.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Cross Connect Server allows Cross Connect Client to connect to it.", + "category": "Tools", + "depends": ["extendable_fastapi", "server_environment"], + "website": "https://github.com/OCA/server-auth", + "data": [ + "security/res_groups.xml", + "security/ir_model_access.xml", + "views/fastapi_endpoint_views.xml", + ], + "maintainers": ["paradoxxxzero"], + "demo": [], + "installable": True, + "license": "AGPL-3", + "external_dependencies": { + "python": ["pyjwt"], + }, +} diff --git a/cross_connect_server/dependencies.py b/cross_connect_server/dependencies.py new file mode 100644 index 000000000..6f2ce7c3a --- /dev/null +++ b/cross_connect_server/dependencies.py @@ -0,0 +1,9 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from .models.cross_connect_client import CrossConnectClient + + +def authenticated_cross_connect_client() -> CrossConnectClient: + pass diff --git a/cross_connect_server/models/__init__.py b/cross_connect_server/models/__init__.py new file mode 100644 index 000000000..108ba9f5c --- /dev/null +++ b/cross_connect_server/models/__init__.py @@ -0,0 +1,3 @@ +from . import cross_connect_client +from . import fastapi_endpoint +from . import res_users diff --git a/cross_connect_server/models/cross_connect_client.py b/cross_connect_server/models/cross_connect_client.py new file mode 100644 index 000000000..37e94fe85 --- /dev/null +++ b/cross_connect_server/models/cross_connect_client.py @@ -0,0 +1,117 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from datetime import datetime, timedelta, timezone +from secrets import token_urlsafe + +import jwt + +from odoo import _, api, fields, models +from odoo.exceptions import AccessDenied + + +class CrossConnectClient(models.Model): + _name = "cross.connect.client" + _description = "Cross Connect Client" + _inherit = "server.env.mixin" + + name = fields.Char(required=True) + + endpoint_id = fields.Many2one( + "fastapi.endpoint", + required=True, + string="Endpoint", + ) + + api_key = fields.Char( + required=True, + string="API Key", + help="The API key to give to configure on the client.", + default=lambda self: self._generate_api_key(), + ) + + allowed_group_ids = fields.Many2many( + related="endpoint_id.cross_connect_allowed_group_ids", + ) + + group_ids = fields.Many2many( + "res.groups", + string="Groups", + help="The groups that this client belongs to.", + domain="[('id', 'in', allowed_group_ids)]", + ) + + user_ids = fields.One2many( + "res.users", + "cross_connect_client_id", + string="Users", + help="The users created by this cross connection.", + ) + user_count = fields.Integer( + compute="_compute_user_count", + string="Cross Connected User Count", + help="The number of users created by this cross connection.", + ) + + @api.model + def _generate_api_key(self): + # generate random ~64 chars secret key + return token_urlsafe(64) + + @api.depends("user_ids") + def _compute_user_count(self): + for record in self: + record.user_count = len(record.user_ids) + + def _request_access(self, access_request): + # check groups + groups = self.env["res.groups"].browse(access_request.groups) + if groups - self.group_ids or not groups.exists(): + raise AccessDenied(_("You are not allowed to access this endpoint.")) + + user = self.user_ids.filtered( + lambda u: u.cross_connect_client_user_id == access_request.id + ) + vals = { + "login": access_request.login, + "name": access_request.name, + "lang": access_request.lang, + "groups_id": [(6, 0, groups.ids)], + "cross_connect_client_id": self.id, + "cross_connect_client_user_id": access_request.id, + } + # Create user if not exists + if not user: + user = self.env["res.users"].create(vals) + else: + user.write(vals) + + return jwt.encode( + { + "exp": datetime.now(tz=timezone.utc) + timedelta(minutes=2), + "aud": str(self.id), + "id": user.id, + "redirect_url": access_request.redirect_url or "/web", + }, + self.endpoint_id.cross_connect_secret_key, + algorithm="HS256", + ) + + def _log_from_token(self, token): + try: + obj = jwt.decode( + token, + self.endpoint_id.cross_connect_secret_key, + audience=str(self.id), + options={"require": ["exp", "aud", "id"]}, + algorithms=["HS256"], + ) + except jwt.PyJWTError as e: + raise AccessDenied(_("Invalid Token")) from e + + user = self.env["res.users"].browse(obj["id"]) + + if not user: + raise AccessDenied(_("Invalid Token")) + + return user, obj["redirect_url"] diff --git a/cross_connect_server/models/fastapi_endpoint.py b/cross_connect_server/models/fastapi_endpoint.py new file mode 100644 index 000000000..774e48534 --- /dev/null +++ b/cross_connect_server/models/fastapi_endpoint.py @@ -0,0 +1,104 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from secrets import token_urlsafe +from typing import Annotated, Callable, Dict, List + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import APIKeyHeader + +from odoo import api, fields, models +from odoo.api import Environment + +from odoo.addons.fastapi.dependencies import fastapi_endpoint, odoo_env + +from ..dependencies import authenticated_cross_connect_client +from ..routers import cross_connect_router +from .cross_connect_client import CrossConnectClient + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + app = fields.Selection( + selection_add=[("cross_connect", "Cross Connect Endpoint")], + ondelete={"cross_connect": "cascade"}, + ) + + cross_connect_client_ids = fields.One2many( + "cross.connect.client", + "endpoint_id", + string="Cross Connect Clients", + help="The clients that can access this endpoint.", + ) + cross_connect_allowed_group_ids = fields.Many2many( + "res.groups", + string="Cross Connect Allowed Groups", + help="The groups that can access the cross connect clients of this endpoint.", + ) + cross_connect_secret_key = fields.Char( + help="The secret key used for cross connection.", + required=True, + default=lambda self: self._generate_secret_key(), + ) + + @api.model + def _generate_secret_key(self): + # generate random ~64 chars secret key + return token_urlsafe(64) + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + + if self.app == "cross_connect": + routers += [cross_connect_router] + + return routers + + def _get_app_dependencies_overrides(self) -> Dict[Callable, Callable]: + overrides = super()._get_app_dependencies_overrides() + + if self.app == "cross_connect": + overrides[ + authenticated_cross_connect_client + ] = api_key_based_authenticated_cross_connect_client + + return overrides + + def _get_routing_info(self): + if self.app == "cross_connect": + # Force to not save the HTTP session for the login to work correctly + self.save_http_session = False + return super()._get_routing_info() + + @property + def _server_env_fields(self): + return {"cross_connect_secret_key": {}} + + +def api_key_based_authenticated_cross_connect_client( + api_key: Annotated[ + str, + Depends( + APIKeyHeader( + name="api-key", + description="Cross Connect Client API key.", + ) + ), + ], + fastapi_endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + env: Annotated[Environment, Depends(odoo_env)], +) -> CrossConnectClient: + cross_connect_client = ( + env["cross.connect.client"] + .sudo() + .search( + [("api_key", "=", api_key), ("endpoint_id", "=", fastapi_endpoint.id)], + limit=1, + ) + ) + if not cross_connect_client: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key" + ) + return cross_connect_client diff --git a/cross_connect_server/models/res_users.py b/cross_connect_server/models/res_users.py new file mode 100644 index 000000000..d98d7ef9c --- /dev/null +++ b/cross_connect_server/models/res_users.py @@ -0,0 +1,19 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + cross_connect_client_id = fields.Many2one( + "cross.connect.client", + string="Cross Connect Client", + help="The cross connect client that created this user.", + ) + cross_connect_client_user_id = fields.Integer( + string="Cross Connect Client User ID", + help="The user ID on the cross connect client.", + ) diff --git a/cross_connect_server/readme/CONTRIBUTORS.md b/cross_connect_server/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..328a37da8 --- /dev/null +++ b/cross_connect_server/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/cross_connect_server/readme/DESCRIPTION.md b/cross_connect_server/readme/DESCRIPTION.md new file mode 100644 index 000000000..4a034abe1 --- /dev/null +++ b/cross_connect_server/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module allows other odoo instances, where the `cross_connect_client` module is +installed and configured, users to connect directly on this odoo instance. diff --git a/cross_connect_server/readme/USAGE.md b/cross_connect_server/readme/USAGE.md new file mode 100644 index 000000000..6d6e80bd5 --- /dev/null +++ b/cross_connect_server/readme/USAGE.md @@ -0,0 +1,17 @@ +First of all after installing the module, you need to configure a fastapi endpoint. + +In order to do that, you need to go to the menu `FastAPI > FastAPI Endpoints` and create +a new endpoint for the client to connect to. + +Fill the fields with the endpoint's information : + +- App: `cross_connect` +- Cross Connect Allowed Groups: The groups that will be allowed to be selected for the + clients groups. + +Then for each client, you will have to add an entry in the `Cross Connect Clients` +table. + +An api key will be automatically generated for each client, this is the key that you +will have to provide to the client in order for them to connect to the server. You will +also have to choose the groups that this client will be able to give to its users. diff --git a/cross_connect_server/routers/__init__.py b/cross_connect_server/routers/__init__.py new file mode 100644 index 000000000..114cbd2c8 --- /dev/null +++ b/cross_connect_server/routers/__init__.py @@ -0,0 +1 @@ +from .cross_connect import cross_connect_router diff --git a/cross_connect_server/routers/cross_connect.py b/cross_connect_server/routers/cross_connect.py new file mode 100644 index 000000000..94fe4ee83 --- /dev/null +++ b/cross_connect_server/routers/cross_connect.py @@ -0,0 +1,78 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import RedirectResponse + +from odoo import _, api +from odoo.exceptions import MissingError +from odoo.http import SESSION_LIFETIME, root + +from odoo.addons.fastapi.dependencies import odoo_env + +from ..dependencies import authenticated_cross_connect_client +from ..models.cross_connect_client import CrossConnectClient +from ..schemas import AccessRequest, AccessResponse, SyncResponse + +cross_connect_router = APIRouter(tags=["Cross Connect"]) + + +@cross_connect_router.get("/cross_connect/sync") +async def sync( + cross_connect_client: Annotated[ + CrossConnectClient, Depends(authenticated_cross_connect_client) + ], +) -> SyncResponse: + """Send back to client sync information.""" + return SyncResponse.from_groups(cross_connect_client.group_ids) + + +@cross_connect_router.post("/cross_connect/access") +async def access( + cross_connect_client: Annotated[ + CrossConnectClient, Depends(authenticated_cross_connect_client) + ], + access_request: AccessRequest, +) -> AccessResponse: + """Send back to client a token.""" + return AccessResponse.from_params( + client_id=cross_connect_client.id, + token=cross_connect_client.sudo()._request_access(access_request), + ) + + +@cross_connect_router.get("/cross_connect/login/{client_id}/{token}") +async def login( + client_id: int, + token: str, + env: Annotated[api.Environment, Depends(odoo_env)], +) -> RedirectResponse: + """Log user and redirect to odoo index.""" + cross_connect_client = env["cross.connect.client"].sudo().browse(client_id) + if not cross_connect_client: + raise MissingError(_("Client not found")) + user, redirect_url = cross_connect_client.sudo()._log_from_token(token) + user = user.with_user(user) + user._update_last_login() + env = env(user=user.id) + + # Create a odoo session + session = root.session_store.new() + session.db = env.cr.dbname + session.uid = user.id + session.login = user.login + session.context = dict(env["res.users"].context_get()) + session.session_token = user._compute_session_token(session.sid) + root.session_store.save(session) + # Redirect after login + response = RedirectResponse(url=redirect_url) + response.set_cookie( + "session_id", + session.sid, + httponly=True, + max_age=SESSION_LIFETIME, + ) + return response diff --git a/cross_connect_server/schemas.py b/cross_connect_server/schemas.py new file mode 100644 index 000000000..684ff0b74 --- /dev/null +++ b/cross_connect_server/schemas.py @@ -0,0 +1,46 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from extendable_pydantic import StrictExtendableBaseModel + + +class CrossConnectGroup(StrictExtendableBaseModel): + id: int + name: str + comment: str | None = None + + @classmethod + def from_group(cls, group): + return cls.model_construct( + id=group.id, + name=group.full_name, + comment=group.comment or None, + ) + + +class SyncResponse(StrictExtendableBaseModel): + groups: list[CrossConnectGroup] + + @classmethod + def from_groups(cls, groups): + return cls.model_construct( + groups=[CrossConnectGroup.from_group(group) for group in groups] + ) + + +class AccessRequest(StrictExtendableBaseModel, extra="ignore"): + id: int + name: str + login: str + lang: str + groups: list[int] + redirect_url: str = None + + +class AccessResponse(StrictExtendableBaseModel): + client_id: int + token: str + + @classmethod + def from_params(cls, token, client_id): + return cls.model_construct(token=token, client_id=client_id) diff --git a/cross_connect_server/security/ir_model_access.xml b/cross_connect_server/security/ir_model_access.xml new file mode 100644 index 000000000..cac56e139 --- /dev/null +++ b/cross_connect_server/security/ir_model_access.xml @@ -0,0 +1,17 @@ + + + + + Cross Connect Client: Manager RW access + + + + + + + + diff --git a/cross_connect_server/security/res_groups.xml b/cross_connect_server/security/res_groups.xml new file mode 100644 index 000000000..33defad93 --- /dev/null +++ b/cross_connect_server/security/res_groups.xml @@ -0,0 +1,15 @@ + + + + + Cross Connect Manager + + + diff --git a/cross_connect_server/static/description/index.html b/cross_connect_server/static/description/index.html new file mode 100644 index 000000000..fed0bcb4e --- /dev/null +++ b/cross_connect_server/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +Cross Connect Server + + + +
+

Cross Connect Server

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module allows other odoo instances, where the +cross_connect_client module is installed and configured, users to +connect directly on this odoo instance.

+

Table of contents

+ +
+

Usage

+

First of all after installing the module, you need to configure a +fastapi endpoint.

+

In order to do that, you need to go to the menu +FastAPI > FastAPI Endpoints and create a new endpoint for the client +to connect to.

+

Fill the fields with the endpoint’s information :

+
    +
  • App: cross_connect
  • +
  • Cross Connect Allowed Groups: The groups that will be allowed to be +selected for the clients groups.
  • +
+

Then for each client, you will have to add an entry in the +Cross Connect Clients table.

+

An api key will be automatically generated for each client, this is the +key that you will have to provide to the client in order for them to +connect to the server. You will also have to choose the groups that this +client will be able to give to its users.

+
+
+

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

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

paradoxxxzero

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/cross_connect_server/tests/__init__.py b/cross_connect_server/tests/__init__.py new file mode 100644 index 000000000..f49cef96d --- /dev/null +++ b/cross_connect_server/tests/__init__.py @@ -0,0 +1 @@ +from . import test_cross_connect_server diff --git a/cross_connect_server/tests/test_cross_connect_server.py b/cross_connect_server/tests/test_cross_connect_server.py new file mode 100644 index 000000000..82d841c9c --- /dev/null +++ b/cross_connect_server/tests/test_cross_connect_server.py @@ -0,0 +1,363 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo.http import root +from odoo.tests.common import RecordCapturer, tagged + +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase + +from ..routers import cross_connect_router + + +@tagged("post_install", "-at_install") +class TestCrossConnectServer(FastAPITransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.endpoint = cls.env["fastapi.endpoint"].create( + { + "name": "Cross Connect Server Endpoint", + "root_path": "/api", + "app": "cross_connect", + } + ) + cls.available_groups = ( + cls.env.ref("base.group_user") + | cls.env.ref("fastapi.group_fastapi_user") + | cls.env.ref("fastapi.group_fastapi_manager") + ) + + cls.endpoint.cross_connect_allowed_group_ids = cls.available_groups + + cls.client = cls.env["cross.connect.client"].create( + { + "name": "Test Client", + "endpoint_id": cls.endpoint.id, + "api_key": "server-api-key", + "group_ids": [ + ( + 6, + 0, + ( + cls.available_groups + - cls.env.ref("fastapi.group_fastapi_manager") + ).ids, + ) + ], + } + ) + + cls.other_client = cls.env["cross.connect.client"].create( + { + "name": "Other Test Client", + "endpoint_id": cls.endpoint.id, + "api_key": "other-server-api-key", + "group_ids": [ + ( + 6, + 0, + (cls.available_groups - cls.env.ref("base.group_user")).ids, + ) + ], + } + ) + + cls.endpoint_user = cls.env["res.users"].create( + { + "name": "FastAPI Endpoint User", + "login": "fastapi_endpoint_user", + "groups_id": [ + (6, 0, [cls.env.ref("fastapi.group_fastapi_endpoint_runner").id]) + ], + } + ) + + cls.endpoint._handle_registry_sync(cls.endpoint.ids) + + cls.default_fastapi_running_user = cls.endpoint_user + cls.default_fastapi_router = cross_connect_router + cls.default_fastapi_app = cls.endpoint._get_app() + cls.default_fastapi_dependency_overrides = ( + cls.default_fastapi_app.dependency_overrides + ) + cls.default_fastapi_app.exception_handlers = {} + + def test_base(self): + self.assertTrue(self.endpoint.cross_connect_secret_key) + self.assertEqual(len(self.endpoint.cross_connect_client_ids), 2) + self.assertFalse(self.endpoint.save_http_session) + self.assertFalse(self.client.user_ids) + + def test_sync_ok(self): + with self._create_test_client() as test_client: + response = test_client.get( + "/cross_connect/sync", headers={"api-key": "server-api-key"} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "groups": [ + { + "id": self.env.ref("base.group_user").id, + "name": "User types / Internal User", + "comment": None, + }, + { + "id": self.env.ref("fastapi.group_fastapi_user").id, + "name": "FastAPI / User", + "comment": None, + }, + ] + }, + ) + + def test_sync_other(self): + with self._create_test_client() as test_client: + response = test_client.get( + "/cross_connect/sync", headers={"api-key": "other-server-api-key"} + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "groups": [ + { + "id": self.env.ref("fastapi.group_fastapi_manager").id, + "name": "FastAPI / Administrator", + "comment": None, + }, + { + "id": self.env.ref("fastapi.group_fastapi_user").id, + "name": "FastAPI / User", + "comment": None, + }, + ] + }, + ) + + def test_sync_401(self): + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.get( + "/cross_connect/sync", headers={"api-key": "wrong-api-key"} + ) + self.assertEqual(response.status_code, 401) + + def test_access_ok(self): + with RecordCapturer(self.env["res.users"], []) as rc: + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("base.group_user").id, + ], + }, + ) + + self.assertEqual(response.status_code, 200) + json = response.json() + self.assertEqual(json["client_id"], self.client.id) + self.assertTrue(json["token"]) + + self.assertEqual(len(rc.records), 1) + new_user = rc.records[0] + self.assertEqual(new_user.name, "Client User") + self.assertEqual(new_user.login, "user@client.example.org") + self.assertEqual(new_user.lang, "en_US") + self.assertEqual(new_user.cross_connect_client_id.id, self.client.id) + self.assertEqual(new_user.cross_connect_client_user_id, 12) + self.assertIn( + self.env.ref("base.group_user"), + new_user.groups_id, + ) + self.assertNotIn(self.env.ref("fastapi.group_fastapi_user"), new_user.groups_id) + self.assertNotIn( + self.env.ref("fastapi.group_fastapi_manager"), new_user.groups_id + ) + + def test_access_401(self): + with RecordCapturer(self.env["res.users"], []) as rc: + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "wrong-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("base.group_user").id, + ], + }, + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(len(rc.records), 0) + + def test_access_wrong_groups(self): + with RecordCapturer(self.env["res.users"], []) as rc: + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "wrong-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("fastapi.group_fastapi_manager").id, + ], + }, + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(len(rc.records), 0) + + def test_access_existing(self): + with RecordCapturer(self.env["res.users"], []) as rc: + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("base.group_user").id, + ], + }, + ) + self.assertEqual(response.status_code, 200) + + with RecordCapturer(self.env["res.users"], []) as rc2: + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": 12, + "name": "Client User2", + "login": "user2@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("fastapi.group_fastapi_user").id, + ], + }, + ) + + self.assertEqual(response.status_code, 200) + json = response.json() + self.assertEqual(json["client_id"], self.client.id) + self.assertTrue(json["token"]) + + self.assertEqual(len(rc.records), 1) + self.assertEqual(len(rc2.records), 0) + new_user = rc.records[0] + self.assertEqual(new_user.name, "Client User2") + self.assertEqual(new_user.login, "user2@client.example.org") + self.assertEqual(new_user.lang, "en_US") + self.assertIn(self.env.ref("fastapi.group_fastapi_user"), new_user.groups_id) + self.assertNotIn( + self.env.ref("fastapi.group_fastapi_manager"), new_user.groups_id + ) + + def test_login_ok(self): + with RecordCapturer(self.env["res.users"], []) as rc: + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("base.group_user").id, + ], + }, + ) + self.assertEqual(response.status_code, 200) + + new_user = rc.records[0] + + json = response.json() + + with self._create_test_client() as test_client: + response = test_client.get( + f"/cross_connect/login/{json['client_id']}/{json['token']}", + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 307) + self.assertEqual(response.headers["location"], "/web") + self.assertIn("session_id", response.cookies) + self.assertEqual( + root.session_store.get(response.cookies["session_id"]).get("uid"), + new_user.id, + ) + + def test_login_wrong_client(self): + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("base.group_user").id, + ], + }, + ) + self.assertEqual(response.status_code, 200) + + json = response.json() + + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.get( + f"/cross_connect/login/{self.other_client.id}/{json['token']}", + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 403) + + def test_login_wrong_token(self): + with self._create_test_client() as test_client: + response = test_client.post( + "/cross_connect/access", + headers={"api-key": "server-api-key"}, + json={ + "id": 12, + "name": "Client User", + "login": "user@client.example.org", + "lang": "en_US", + "groups": [ + self.env.ref("base.group_user").id, + ], + }, + ) + self.assertEqual(response.status_code, 200) + + json = response.json() + + with self._create_test_client(raise_server_exceptions=False) as test_client: + response = test_client.get( + f"/cross_connect/login/{json['client_id']}/wrong-token", + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 403) diff --git a/cross_connect_server/views/fastapi_endpoint_views.xml b/cross_connect_server/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..41f2c4660 --- /dev/null +++ b/cross_connect_server/views/fastapi_endpoint_views.xml @@ -0,0 +1,46 @@ + + + + + fastapi.endpoint + + + + + {'invisible': [('app', '==', 'cross_connect')]} + + + + + + + + + + + + + + + + + + + diff --git a/setup/cross_connect_server/odoo/addons/cross_connect_server b/setup/cross_connect_server/odoo/addons/cross_connect_server new file mode 120000 index 000000000..6004e2f0b --- /dev/null +++ b/setup/cross_connect_server/odoo/addons/cross_connect_server @@ -0,0 +1 @@ +../../../../cross_connect_server \ No newline at end of file diff --git a/setup/cross_connect_server/setup.py b/setup/cross_connect_server/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/cross_connect_server/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 2cb24f43d..265b7f6b1 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,2 @@ responses +httpx