Tower: upload cetmix_tower_webhook 18.0.1.0.1 (was 18.0.1.0.1, via marketplace)
This commit is contained in:
9
addons/cetmix_tower_webhook/models/__init__.py
Normal file
9
addons/cetmix_tower_webhook/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import cx_tower_webhook_eval_mixin
|
||||
from . import cx_tower_webhook_authenticator
|
||||
from . import cx_tower_webhook_log
|
||||
from . import cx_tower_webhook
|
||||
from . import cx_tower_variable
|
||||
from . import res_config_settings
|
||||
70
addons/cetmix_tower_webhook/models/constants.py
Normal file
70
addons/cetmix_tower_webhook/models/constants.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# flake8: noqa: E501
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
|
||||
# Default Python code used in Webhook Authenticator
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE = """# Please refer to the 'Help' tab and documentation for more information.
|
||||
#
|
||||
# You can return authenticator result in the 'result' variable which is a dictionary:
|
||||
# result = {"allowed": <bool, mandatory, default=False>, "http_code": <int, optional>, "message": <str, optional>}
|
||||
# default value is {"allowed": False}
|
||||
"""
|
||||
|
||||
# Default Python code help used in Webhook Authenticator
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP = """
|
||||
<h3>Help for Webhook Authenticator Python Code</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p>
|
||||
The Python code for the webhook authenticator must return the <code>result</code> variable, which is a dictionary.<br>
|
||||
<strong>Allowed keys:</strong>
|
||||
<ul>
|
||||
<li><code>allowed</code> (<b>bool</b>, required): Authentication result. <code>True</code> if allowed, <code>False</code> otherwise.</li>
|
||||
<li><code>http_code</code> (<b>int</b>, optional): HTTP status code to return if authentication fails (default is 403).</li>
|
||||
<li><code>message</code> (<b>str</b>, optional): Error message to show to the client.</li>
|
||||
</ul>
|
||||
<strong>Examples:</strong>
|
||||
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
|
||||
# Allow all requests
|
||||
result = {"allowed": True}
|
||||
|
||||
# Deny with custom code and message
|
||||
result = {"allowed": False, "http_code": 401, "message": "Unauthorized request"}
|
||||
</pre>
|
||||
</p>
|
||||
<strong>Available variables:</strong>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# Default Python code used in Webhook
|
||||
DEFAULT_WEBHOOK_CODE = """# Please refer to the 'Help' tab and documentation for more information.
|
||||
#
|
||||
# You can return webhook result in the 'result' variable which is a dictionary:
|
||||
# result = {"exit_code": <int, default=0>, "message": <string, default=None>}
|
||||
# default value is {"exit_code": 0, "message": None}
|
||||
"""
|
||||
|
||||
# Default Python code help used in Webhook
|
||||
DEFAULT_WEBHOOK_CODE_HELP = """
|
||||
<h3>Help for Webhook Python Code</h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<p>
|
||||
The webhook Python code must set the <code>result</code> variable, which is a dictionary.<br>
|
||||
<strong>Allowed keys:</strong>
|
||||
<ul>
|
||||
<li><code>exit_code</code> (<b>int</b>, optional, default=0): Exit code (0 means success, other values indicate failure).</li>
|
||||
<li><code>message</code> (<b>str</b>, optional): Message to return in the HTTP response and log.</li>
|
||||
</ul>
|
||||
<strong>Example:</strong>
|
||||
<pre style='background:#f7f7f7; padding:6px; border-radius:4px'>
|
||||
# Simple successful result
|
||||
result = {"exit_code": 0, "message": "Webhook processed successfully"}
|
||||
|
||||
# Failure example
|
||||
result = {"exit_code": 1, "message": "Something went wrong"}
|
||||
</pre>
|
||||
</p>
|
||||
<strong>Available variables:</strong>
|
||||
</div>
|
||||
"""
|
||||
85
addons/cetmix_tower_webhook/models/cx_tower_variable.py
Normal file
85
addons/cetmix_tower_webhook/models/cx_tower_variable.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class CxTowerVariable(models.Model):
|
||||
_inherit = "cx.tower.variable"
|
||||
|
||||
# --- Link to records where the variable is used
|
||||
webhook_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.webhook",
|
||||
relation="cx_tower_webhook_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="webhook_id",
|
||||
copy=False,
|
||||
)
|
||||
webhook_ids_count = fields.Integer(
|
||||
string="Webhook Count", compute="_compute_webhook_ids_count"
|
||||
)
|
||||
webhook_authenticator_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.webhook.authenticator",
|
||||
relation="cx_tower_webhook_authenticator_variable_rel",
|
||||
column1="variable_id",
|
||||
column2="webhook_authenticator_id",
|
||||
copy=False,
|
||||
)
|
||||
webhook_authenticator_ids_count = fields.Integer(
|
||||
string="Webhook Authenticator Count", compute="_compute_webhook_ids_count"
|
||||
)
|
||||
|
||||
def _compute_webhook_ids_count(self):
|
||||
"""
|
||||
Count number of webhooks and webhook authenticators for the variable
|
||||
"""
|
||||
for rec in self:
|
||||
rec.update(
|
||||
{
|
||||
"webhook_ids_count": len(rec.webhook_ids),
|
||||
"webhook_authenticator_ids_count": len(
|
||||
rec.webhook_authenticator_ids
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def action_open_webhooks(self):
|
||||
"""Open the webhooks where the variable is used"""
|
||||
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_action"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def action_open_webhook_authenticators(self):
|
||||
"""Open the webhook authenticators where the variable is used"""
|
||||
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_authenticator_action"
|
||||
)
|
||||
action.update(
|
||||
{
|
||||
"domain": [("variable_ids", "in", self.ids)],
|
||||
}
|
||||
)
|
||||
return action
|
||||
|
||||
def _get_propagation_field_mapping(self):
|
||||
"""
|
||||
Override to add webhook and webhook authenticator
|
||||
to the propagation field mapping.
|
||||
"""
|
||||
res = super()._get_propagation_field_mapping()
|
||||
res.update(
|
||||
{
|
||||
"cx.tower.webhook": ["code"],
|
||||
"cx.tower.webhook.authenticator": ["code"],
|
||||
}
|
||||
)
|
||||
return res
|
||||
221
addons/cetmix_tower_webhook/models/cx_tower_webhook.py
Normal file
221
addons/cetmix_tower_webhook/models/cx_tower_webhook.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import re
|
||||
|
||||
from odoo import SUPERUSER_ID, _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .constants import DEFAULT_WEBHOOK_CODE, DEFAULT_WEBHOOK_CODE_HELP
|
||||
|
||||
|
||||
class CxTowerWebhook(models.Model):
|
||||
"""Webhook"""
|
||||
|
||||
_name = "cx.tower.webhook"
|
||||
_inherit = [
|
||||
"cx.tower.webhook.eval.mixin",
|
||||
]
|
||||
_description = "Webhook"
|
||||
|
||||
active = fields.Boolean(
|
||||
default=True,
|
||||
string="Enabled",
|
||||
)
|
||||
authenticator_id = fields.Many2one(
|
||||
comodel_name="cx.tower.webhook.authenticator",
|
||||
required=True,
|
||||
help="Select an Authenticator used for this webhook",
|
||||
)
|
||||
endpoint = fields.Char(
|
||||
required=True,
|
||||
copy=False,
|
||||
help="Webhook endpoint. The complete URL will be "
|
||||
"<your_tower_url>/cetmix_tower_webhooks/<endpoint>",
|
||||
)
|
||||
full_url = fields.Char(
|
||||
compute="_compute_full_url",
|
||||
help="Full URL of the webhook",
|
||||
)
|
||||
method = fields.Selection(
|
||||
[
|
||||
("post", "POST"),
|
||||
("get", "GET"),
|
||||
],
|
||||
default="post",
|
||||
required=True,
|
||||
help="Select the HTTP method for this webhook",
|
||||
)
|
||||
content_type = fields.Selection(
|
||||
[
|
||||
("json", "JSON"),
|
||||
("form", "Form URL-Encoded"),
|
||||
],
|
||||
string="Payload Type",
|
||||
default="json",
|
||||
required=True,
|
||||
help="How the payload is expected to be sent to this webhook: "
|
||||
"as JSON body or as URL-encoded form data",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
comodel_name="res.users",
|
||||
string="Run as User",
|
||||
help="Select a user to run the webhook from behalf of. If not set, "
|
||||
"the webhook will run as the current user.\n"
|
||||
"CAREFUL! You must realise and understand what you are doing including "
|
||||
"all the possible consequences when selecting a specific user",
|
||||
default=SUPERUSER_ID,
|
||||
required=True,
|
||||
copy=False,
|
||||
)
|
||||
log_count = fields.Integer(
|
||||
compute="_compute_log_count",
|
||||
)
|
||||
variable_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.variable",
|
||||
relation="cx_tower_webhook_variable_rel",
|
||||
column1="webhook_id",
|
||||
column2="variable_id",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
"endpoint_method_uniq",
|
||||
"unique(endpoint, method)",
|
||||
"Endpoint and method must be unique!",
|
||||
),
|
||||
]
|
||||
|
||||
def _compute_log_count(self):
|
||||
"""Compute log count."""
|
||||
data = {
|
||||
webhook.id: count
|
||||
for webhook, count in self.env["cx.tower.webhook.log"]._read_group(
|
||||
domain=[("webhook_id", "in", self.ids)],
|
||||
groupby=["webhook_id"],
|
||||
aggregates=["__count"],
|
||||
)
|
||||
}
|
||||
for rec in self:
|
||||
rec.log_count = data.get(rec.id, 0)
|
||||
|
||||
@api.depends("endpoint")
|
||||
def _compute_full_url(self):
|
||||
"""Compute full URL."""
|
||||
base_url = (
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("web.base.url", "")
|
||||
.rstrip("/")
|
||||
)
|
||||
for rec in self:
|
||||
rec.full_url = f"{base_url}/cetmix_tower_webhooks/{rec.endpoint}"
|
||||
|
||||
@api.constrains("endpoint")
|
||||
def _check_endpoint_format(self):
|
||||
"""Validate endpoint format."""
|
||||
pattern = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_/-]*[A-Za-z0-9])?$")
|
||||
for rec in self:
|
||||
if rec.endpoint and not pattern.fullmatch(rec.endpoint):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"Endpoint must start and end with a letter or digit, "
|
||||
"and may contain underscores, dashes, and slashes in between"
|
||||
)
|
||||
)
|
||||
|
||||
def _default_eval_code(self):
|
||||
"""
|
||||
Returns the default code for the webhook.
|
||||
"""
|
||||
return _(DEFAULT_WEBHOOK_CODE)
|
||||
|
||||
def _get_default_python_eval_code_help(self):
|
||||
"""
|
||||
Returns the default code help for the webhook.
|
||||
"""
|
||||
return _(DEFAULT_WEBHOOK_CODE_HELP)
|
||||
|
||||
def _get_python_eval_odoo_objects(self, **kwargs):
|
||||
"""
|
||||
Override to add custom Odoo objects.
|
||||
"""
|
||||
res = {
|
||||
"headers": {
|
||||
"import": kwargs.get("headers"),
|
||||
"help": _("Dictionary of request headers"),
|
||||
},
|
||||
"payload": {
|
||||
"import": kwargs.get("payload"),
|
||||
"help": _(
|
||||
"Dictionary containing the request payload "
|
||||
"(JSON for POST, params for GET)"
|
||||
),
|
||||
},
|
||||
}
|
||||
res.update(super()._get_python_eval_odoo_objects(**kwargs))
|
||||
return res
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
"""Override to add fields to YAML export."""
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"active",
|
||||
"authenticator_id",
|
||||
"endpoint",
|
||||
"method",
|
||||
"code",
|
||||
"content_type",
|
||||
"variable_ids",
|
||||
"secret_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def execute(self, payload=None, raise_on_error=True, **kwargs):
|
||||
"""
|
||||
Run the webhook code and return a validated result.
|
||||
Handles errors and checks result format.
|
||||
|
||||
Args:
|
||||
payload (dict): The webhook payload. If not provided,
|
||||
the payload will be empty.
|
||||
raise_on_error (bool): Raise ValidationError on error if True.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'exit_code': <int>,
|
||||
'message': <str>
|
||||
}
|
||||
"""
|
||||
self.ensure_one()
|
||||
self_with_user = self.with_user(self.user_id)
|
||||
payload = payload or {}
|
||||
|
||||
try:
|
||||
result = self_with_user._run_webhook_eval_code(
|
||||
self_with_user.code,
|
||||
context_extra={"payload": payload, "headers": kwargs.get("headers")},
|
||||
default_result={"exit_code": 0, "message": None},
|
||||
)
|
||||
except Exception as e:
|
||||
if raise_on_error:
|
||||
raise ValidationError(
|
||||
_("Webhook code execution error: %(error)s", error=e)
|
||||
) from e
|
||||
result = {
|
||||
"exit_code": 1,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def action_view_logs(self):
|
||||
"""Open logs related to this webhook."""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_log_action"
|
||||
)
|
||||
action["domain"] = [("webhook_id", "=", self.id)]
|
||||
return action
|
||||
@@ -0,0 +1,383 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
from .constants import (
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE,
|
||||
DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CxTowerWebhookAuthenticator(models.Model):
|
||||
"""Webhook Authenticator"""
|
||||
|
||||
_name = "cx.tower.webhook.authenticator"
|
||||
_inherit = [
|
||||
"cx.tower.webhook.eval.mixin",
|
||||
]
|
||||
_description = "Webhook Authenticator"
|
||||
|
||||
log_count = fields.Integer(
|
||||
compute="_compute_log_count",
|
||||
)
|
||||
allowed_ip_addresses = fields.Text(
|
||||
string="Allowed IPs",
|
||||
help="Comma-separated list of IP addresses and/or subnets "
|
||||
"(e.g. 192.168.1.10,192.168.2.0/24,10.0.0.1,2001:db8::/32,2a00:1450:4001:824::200e). " # noqa: E501
|
||||
"Requests from other addresses will be denied.",
|
||||
)
|
||||
trusted_proxy_ips = fields.Text(
|
||||
string="Trusted Proxy IPs",
|
||||
help="Comma-separated list of trusted proxy IP addresses or CIDR ranges "
|
||||
"(e.g., 10.0.0.1,192.168.1.0/24). "
|
||||
"Only these proxies can set X-Forwarded-For headers.",
|
||||
)
|
||||
variable_ids = fields.Many2many(
|
||||
comodel_name="cx.tower.variable",
|
||||
relation="cx_tower_webhook_authenticator_variable_rel",
|
||||
column1="webhook_authenticator_id",
|
||||
column2="variable_id",
|
||||
)
|
||||
|
||||
@api.constrains("trusted_proxy_ips")
|
||||
def _check_trusted_proxy_ips(self):
|
||||
"""
|
||||
Validate 'trusted_proxy_ips' entries. Accepts single IPs and CIDR ranges
|
||||
(IPv4/IPv6). Empty value is allowed.
|
||||
"""
|
||||
for rec in self:
|
||||
invalid = self._validate_ip_token((rec.trusted_proxy_ips or "").strip())
|
||||
if invalid:
|
||||
raise ValidationError(_("Invalid trusted proxy entry: %s") % invalid)
|
||||
|
||||
@api.constrains("allowed_ip_addresses")
|
||||
def _check_allowed_ip_addresses(self):
|
||||
"""
|
||||
Validate 'allowed_ip_addresses' entries. Accepts single IPs and CIDR
|
||||
ranges (IPv4/IPv6). Empty value is allowed (means allow all).
|
||||
"""
|
||||
for rec in self:
|
||||
invalid = self._validate_ip_token((rec.allowed_ip_addresses or "").strip())
|
||||
if invalid:
|
||||
raise ValidationError(_("Invalid allowed IP/CIDR entry: %s") % invalid)
|
||||
|
||||
def _compute_log_count(self):
|
||||
"""Compute log count."""
|
||||
data = {
|
||||
webhook.id: count
|
||||
for webhook, count in self.env["cx.tower.webhook.log"]._read_group(
|
||||
domain=[("authenticator_id", "in", self.ids)],
|
||||
groupby=["authenticator_id"],
|
||||
aggregates=["__count"],
|
||||
)
|
||||
}
|
||||
for rec in self:
|
||||
rec.log_count = data.get(rec.id, 0)
|
||||
|
||||
def _default_eval_code(self):
|
||||
"""
|
||||
Return the default Python code for the webhook authenticator.
|
||||
|
||||
Returns:
|
||||
str: Default authenticator code.
|
||||
"""
|
||||
return _(DEFAULT_WEBHOOK_AUTHENTICATOR_CODE)
|
||||
|
||||
def _get_default_python_eval_code_help(self):
|
||||
"""
|
||||
Return the default help text for the authenticator code.
|
||||
|
||||
Returns:
|
||||
str: Code help description.
|
||||
"""
|
||||
return _(DEFAULT_WEBHOOK_AUTHENTICATOR_CODE_HELP)
|
||||
|
||||
def _get_python_eval_odoo_objects(self, **kwargs):
|
||||
"""
|
||||
Extend the Python evaluation context with custom Odoo objects.
|
||||
|
||||
Args:
|
||||
**kwargs: Extra context values, e.g.:
|
||||
- "headers": request headers (dict)
|
||||
- "raw_data": request body (bytes)
|
||||
- "payload": parsed request payload (dict)
|
||||
|
||||
Returns:
|
||||
dict: Mapping of variables available in evaluation context.
|
||||
"""
|
||||
res = {
|
||||
"headers": {
|
||||
"import": kwargs.get("headers"),
|
||||
"help": _("Dictionary of request headers"),
|
||||
},
|
||||
"raw_data": {
|
||||
"import": kwargs.get("raw_data"),
|
||||
"help": _("Raw body of the request (bytes)"),
|
||||
},
|
||||
"payload": {
|
||||
"import": kwargs.get("payload"),
|
||||
"help": _(
|
||||
"Dictionary containing the request payload "
|
||||
"(JSON for POST, params for GET)"
|
||||
),
|
||||
},
|
||||
}
|
||||
res.update(super()._get_python_eval_odoo_objects(**kwargs))
|
||||
return res
|
||||
|
||||
def _get_fields_for_yaml(self):
|
||||
"""
|
||||
Extend fields available for YAML export.
|
||||
|
||||
Returns:
|
||||
list[str]: List of field names.
|
||||
"""
|
||||
res = super()._get_fields_for_yaml()
|
||||
res += [
|
||||
"name",
|
||||
"code",
|
||||
"allowed_ip_addresses",
|
||||
"trusted_proxy_ips",
|
||||
"variable_ids",
|
||||
"secret_ids",
|
||||
]
|
||||
return res
|
||||
|
||||
def authenticate(self, raise_on_error=True, **kwargs):
|
||||
"""
|
||||
Run the authenticator code and return result.
|
||||
|
||||
Args:
|
||||
raise_on_error (bool): Raise ValidationError on error if True.
|
||||
kwargs: Additional variables passed to the code context, e.g.:
|
||||
- "headers": request headers (dict)
|
||||
- "raw_data": request body (bytes)
|
||||
- "payload": parsed request payload (dict)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"allowed": <bool>,
|
||||
"http_code": <int, optional>,
|
||||
"message": <str, optional>,
|
||||
}
|
||||
"""
|
||||
self.ensure_one()
|
||||
try:
|
||||
result = self._run_webhook_eval_code(
|
||||
self.code,
|
||||
context_extra={
|
||||
"headers": kwargs.get("headers"),
|
||||
"raw_data": kwargs.get("raw_data"),
|
||||
"payload": kwargs.get("payload"),
|
||||
},
|
||||
default_result={"allowed": False},
|
||||
)
|
||||
except Exception as e:
|
||||
if raise_on_error:
|
||||
raise ValidationError(_("Authentication code error: %s") % e) from e
|
||||
result = {
|
||||
"allowed": False,
|
||||
"http_code": 500,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def action_view_logs(self):
|
||||
"""
|
||||
Open the action displaying logs related to this authenticator.
|
||||
|
||||
Returns:
|
||||
dict: Action dictionary for `ir.actions.act_window`.
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id(
|
||||
"cetmix_tower_webhook.cx_tower_webhook_log_action"
|
||||
)
|
||||
action["domain"] = [("authenticator_id", "=", self.id)]
|
||||
return action
|
||||
|
||||
def is_ip_allowed(self, remote_addr):
|
||||
"""
|
||||
Proxy-aware allowlist check.
|
||||
|
||||
Steps:
|
||||
1) Compute the effective client IP.
|
||||
2) If 'allowed_ip_addresses' is empty: allow everyone (backward compatible).
|
||||
3) Otherwise, allow only if the client IP belongs to any network in
|
||||
'allowed_ip_addresses'.
|
||||
|
||||
Args:
|
||||
remote_addr (str): Immediate TCP peer IP (controller-provided).
|
||||
|
||||
Returns:
|
||||
bool: True if client IP is allowed, False otherwise.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
client_ip = self._effective_client_ip(remote_addr)
|
||||
if not client_ip:
|
||||
return False
|
||||
|
||||
spec = (self.allowed_ip_addresses or "").strip()
|
||||
if not spec:
|
||||
return True
|
||||
|
||||
allowed_nets = self._parse_ip_list_to_networks(spec)
|
||||
if not allowed_nets:
|
||||
# Misconfigured allowlist: fail closed
|
||||
return False
|
||||
|
||||
return any(client_ip in net for net in allowed_nets)
|
||||
|
||||
def _effective_client_ip(self, remote_addr):
|
||||
"""
|
||||
Compute the effective client IP for the current HTTP request.
|
||||
|
||||
Security model:
|
||||
- The immediate TCP peer is 'remote_addr'
|
||||
(or request.httprequest.remote_addr).
|
||||
- X-Forwarded-For / X-Real-IP are honored ONLY if the immediate peer
|
||||
is within 'trusted_proxy_ips' (single IPs or CIDR ranges).
|
||||
- If not behind a trusted proxy, headers are ignored to prevent spoofing.
|
||||
|
||||
Args:
|
||||
remote_addr (str): Immediate TCP peer IP passed by the controller.
|
||||
|
||||
Returns:
|
||||
ipaddress.IPv4Address|ipaddress.IPv6Address|None:
|
||||
Effective client IP or None.
|
||||
"""
|
||||
immediate_peer = remote_addr or getattr(
|
||||
getattr(request, "httprequest", None), "remote_addr", None
|
||||
)
|
||||
if not immediate_peer:
|
||||
return None
|
||||
|
||||
try:
|
||||
immediate_ip = ipaddress.ip_address(immediate_peer)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
client_ip = immediate_ip # default to immediate peer
|
||||
trusted_nets = self._parse_ip_list_to_networks(
|
||||
(self.trusted_proxy_ips or "").strip()
|
||||
)
|
||||
headers = getattr(getattr(request, "httprequest", None), "headers", {}) or {}
|
||||
is_trusted_proxy = (
|
||||
any(immediate_ip in net for net in trusted_nets) if trusted_nets else False
|
||||
)
|
||||
|
||||
if is_trusted_proxy:
|
||||
candidate = self._extract_ip_from_header(headers.get("X-Forwarded-For"))
|
||||
if not candidate:
|
||||
candidate = self._extract_ip_from_header(headers.get("X-Real-IP"))
|
||||
if candidate:
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
# Fall back to immediate peer if candidate is invalid.
|
||||
_logger.warning("Invalid IP/CIDR entry")
|
||||
|
||||
return client_ip
|
||||
|
||||
def _extract_ip_from_header(self, header_value):
|
||||
"""
|
||||
Extract the first valid IP from a proxy-provided header.
|
||||
|
||||
Behavior:
|
||||
- For X-Forwarded-For, the left-most entry is
|
||||
considered the original client IP.
|
||||
- For X-Real-IP, the value itself is considered.
|
||||
- Any non-IP tokens are skipped.
|
||||
|
||||
Args:
|
||||
header_value (str): Raw header value (may contain commas for XFF).
|
||||
|
||||
Returns:
|
||||
str|None: Compressed IPv4/IPv6 string, or None if nothing valid is found.
|
||||
"""
|
||||
if not header_value:
|
||||
return None
|
||||
|
||||
for token in header_value.split(","):
|
||||
ip_str = token.strip()
|
||||
if not ip_str:
|
||||
continue
|
||||
try:
|
||||
return ipaddress.ip_address(ip_str).compressed
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_ip_list_to_networks(spec):
|
||||
"""
|
||||
Convert a CSV of IPs/CIDRs into a list of ip_network objects.
|
||||
Single IPs are normalized to /32 (IPv4) or /128 (IPv6).
|
||||
|
||||
Args:
|
||||
spec (str): CSV of IPs/CIDRs.
|
||||
|
||||
Returns:
|
||||
list[ipaddress.IPv4Network|ipaddress.IPv6Network]
|
||||
"""
|
||||
nets = []
|
||||
if not spec:
|
||||
return nets
|
||||
for part in spec.split(","):
|
||||
s = (part or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(s, strict=False))
|
||||
continue
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"Invalid IP/CIDR entry encountered in "
|
||||
"trusted_proxy_ips configuration."
|
||||
)
|
||||
try:
|
||||
ip = ipaddress.ip_address(s)
|
||||
nets.append(
|
||||
ipaddress.ip_network(
|
||||
ip.exploded + ("/32" if ip.version == 4 else "/128")
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
# Ignore invalid entries silently; validation is handled by constraints.
|
||||
continue
|
||||
return nets
|
||||
|
||||
def _validate_ip_token(self, spec):
|
||||
"""
|
||||
Return the first invalid token from a CSV of IPs/CIDRs,
|
||||
or None if all valid.
|
||||
Accepts single IPs and CIDR ranges (IPv4/IPv6).
|
||||
Empty/whitespace tokens are ignored.
|
||||
"""
|
||||
if not spec:
|
||||
return None
|
||||
for part in spec.split(","):
|
||||
s = (part or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_network(s, strict=False)
|
||||
continue
|
||||
except ValueError:
|
||||
_logger.warning("Invalid IP/CIDR entry encountered")
|
||||
pass
|
||||
try:
|
||||
ipaddress.ip_address(s)
|
||||
except ValueError:
|
||||
return s
|
||||
return None
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
|
||||
class CxTowerWebhookEvalMixin(models.AbstractModel):
|
||||
_name = "cx.tower.webhook.eval.mixin"
|
||||
_inherit = [
|
||||
"cx.tower.template.mixin",
|
||||
"cx.tower.key.mixin",
|
||||
"cx.tower.yaml.mixin",
|
||||
"cx.tower.reference.mixin",
|
||||
]
|
||||
_description = "Eval context/code helper for Cetmix Tower Webhook"
|
||||
|
||||
code_help = fields.Html(
|
||||
compute="_compute_code_help",
|
||||
default=lambda self: self._default_eval_code_help(),
|
||||
compute_sudo=True,
|
||||
)
|
||||
code = fields.Text(
|
||||
default=lambda self: self._default_eval_code(),
|
||||
required=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_depends_fields(cls):
|
||||
"""Add code to the depends fields."""
|
||||
return ["code"]
|
||||
|
||||
def _compute_code_help(self):
|
||||
"""
|
||||
Compute code help
|
||||
"""
|
||||
self.code_help = self._default_eval_code_help()
|
||||
|
||||
def _default_eval_code_help(self):
|
||||
"""
|
||||
Return the default code help text for webhook or authenticator.
|
||||
|
||||
We use default because the computation method for this field
|
||||
would not be triggered before this record is saved. And we need
|
||||
to show the value instantly.
|
||||
|
||||
Returns:
|
||||
str: HTML-formatted help string containing available objects and libraries.
|
||||
"""
|
||||
available_libraries = self._get_python_eval_odoo_objects()
|
||||
available_libraries.update(self._get_python_eval_libraries())
|
||||
help_text_fragments = []
|
||||
for key, value in available_libraries.items():
|
||||
if key == "server":
|
||||
# Server is not available in the webhook/authenticator eval context
|
||||
continue
|
||||
help_text_fragments.append(f"<li><code>{key}</code>: {value['help']}</li>")
|
||||
|
||||
help_text = "<ul>" + "".join(help_text_fragments) + "</ul>"
|
||||
return f"{self._get_default_python_eval_code_help()}{help_text}"
|
||||
|
||||
def _get_python_eval_odoo_objects(self, **kwargs):
|
||||
"""
|
||||
Return Odoo objects available in the eval context.
|
||||
|
||||
Args:
|
||||
**kwargs: Optional context values.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of object names to their import values and help.
|
||||
"""
|
||||
return self.env["cx.tower.command"]._get_python_command_odoo_objects()
|
||||
|
||||
def _get_python_eval_libraries(self):
|
||||
"""
|
||||
Return Python libraries available in the eval context.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of library names to their import values and help.
|
||||
"""
|
||||
return self.env["cx.tower.command"]._get_python_command_libraries()
|
||||
|
||||
def _get_default_python_eval_code_help(self):
|
||||
"""
|
||||
Return the default help text for eval code.
|
||||
|
||||
Returns:
|
||||
str: Help text.
|
||||
"""
|
||||
return ""
|
||||
|
||||
def _default_eval_code(self):
|
||||
"""
|
||||
Return the default code for webhook or authenticator.
|
||||
|
||||
Returns:
|
||||
str: Default Python code.
|
||||
"""
|
||||
return ""
|
||||
|
||||
def _prepare_webhook_eval_context(self, context_extra=None, default_result=None):
|
||||
"""
|
||||
Build the evaluation context for webhook or authenticator
|
||||
safe_eval.
|
||||
|
||||
Args:
|
||||
context_extra (dict): Additional context variables
|
||||
(payload, headers, etc).
|
||||
default_result (dict): Default value for the 'result' variable.
|
||||
|
||||
Returns:
|
||||
dict: Prepared eval context.
|
||||
"""
|
||||
context_extra = context_extra or {}
|
||||
# Get the Odoo objects first
|
||||
imports = self._get_python_eval_odoo_objects(**context_extra)
|
||||
|
||||
# Update with the libraries
|
||||
imports.update(self._get_python_eval_libraries())
|
||||
eval_context = {key: value["import"] for key, value in imports.items()}
|
||||
|
||||
# Remove server from eval context
|
||||
eval_context.pop("server", None)
|
||||
|
||||
# Set default result
|
||||
default_result = default_result or {}
|
||||
eval_context["result"] = default_result.copy()
|
||||
|
||||
return eval_context
|
||||
|
||||
def _run_webhook_eval_code(self, code, **kwargs):
|
||||
"""
|
||||
Helper to execute user code safely. Returns the 'result' variable from context.
|
||||
|
||||
Args:
|
||||
code (str): User code to run
|
||||
kwargs:
|
||||
key (dict): Extra keys for secret parser
|
||||
context_extra (dict): Extra context variables (payload, headers, etc)
|
||||
default_result (dict): Default value for the 'result' variable
|
||||
|
||||
Returns:
|
||||
dict: The 'result' variable from context
|
||||
"""
|
||||
eval_context = self._prepare_webhook_eval_context(**kwargs)
|
||||
|
||||
if not code:
|
||||
# if code is empty, return the default result
|
||||
return eval_context["result"]
|
||||
|
||||
# prepare the code for evaluation
|
||||
code_and_secrets = self.env["cx.tower.key"]._parse_code_and_return_key_values(
|
||||
code, pythonic_mode=True, **kwargs.get("key", {})
|
||||
)
|
||||
secrets = code_and_secrets.get("key_values")
|
||||
webhook_code = code_and_secrets["code"]
|
||||
|
||||
code = self.env["cx.tower.key"]._parse_code(
|
||||
webhook_code, pythonic_mode=True, **kwargs.get("key", {})
|
||||
)
|
||||
|
||||
# execute user code
|
||||
safe_eval(
|
||||
code,
|
||||
eval_context,
|
||||
mode="exec",
|
||||
nocopy=True,
|
||||
)
|
||||
result = eval_context["result"]
|
||||
return self._parse_eval_code_result(result, secrets=secrets, **kwargs)
|
||||
|
||||
def _parse_eval_code_result(self, result, secrets=None, **kwargs):
|
||||
"""
|
||||
Post-processes the result returned from webhook/authenticator eval code.
|
||||
|
||||
If 'secrets' are provided, all occurrences of secret values in the
|
||||
'message' field of result will be replaced with a spoiler string to
|
||||
prevent sensitive information leakage.
|
||||
|
||||
Args:
|
||||
result (dict): The dict returned from the executed eval code,
|
||||
expected to have at least a 'message' key.
|
||||
secrets (dict, optional): A mapping of secret key-value
|
||||
pairs used for replacement in 'message'.
|
||||
|
||||
Returns:
|
||||
dict: The processed result with secrets masked in the 'message'
|
||||
field, if applicable.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
raise ValidationError(
|
||||
_("Webhook/Authenticator code error: result is not a dict")
|
||||
)
|
||||
|
||||
if secrets and isinstance(result.get("message"), str):
|
||||
result["message"] = self.env["cx.tower.key"]._replace_with_spoiler(
|
||||
result["message"], secrets
|
||||
)
|
||||
|
||||
return result
|
||||
197
addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py
Normal file
197
addons/cetmix_tower_webhook/models/cx_tower_webhook_log.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# Copyright (C) 2025 Cetmix OÜ
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class CxTowerWebhookLog(models.Model):
|
||||
"""Webhook Call Log"""
|
||||
|
||||
_name = "cx.tower.webhook.log"
|
||||
_description = "Webhook Call Log"
|
||||
_order = "create_date desc"
|
||||
_rec_name = "display_name"
|
||||
|
||||
webhook_id = fields.Many2one(
|
||||
comodel_name="cx.tower.webhook",
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
help="Webhook that received the call.",
|
||||
)
|
||||
endpoint = fields.Char(
|
||||
readonly=True,
|
||||
)
|
||||
authenticator_id = fields.Many2one(
|
||||
comodel_name="cx.tower.webhook.authenticator",
|
||||
readonly=True,
|
||||
)
|
||||
request_method = fields.Selection(
|
||||
[
|
||||
("post", "POST"),
|
||||
("get", "GET"),
|
||||
],
|
||||
default="post",
|
||||
required=True,
|
||||
help="Select the HTTP method for this webhook.",
|
||||
)
|
||||
request_headers = fields.Text(
|
||||
help="Headers of the received HTTP request (JSON-encoded).",
|
||||
)
|
||||
request_payload = fields.Text(
|
||||
help="Payload/body of the received HTTP request (JSON-encoded).",
|
||||
)
|
||||
authentication_status = fields.Selection(
|
||||
[
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
("not_required", "Not Required"),
|
||||
],
|
||||
required=True,
|
||||
default="failed",
|
||||
help="Result of authentication for this webhook call.",
|
||||
)
|
||||
code_status = fields.Selection(
|
||||
[
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
("skipped", "Skipped"),
|
||||
],
|
||||
string="Webhook Code Status",
|
||||
required=True,
|
||||
default="skipped",
|
||||
help="Result of webhook code execution.",
|
||||
)
|
||||
http_status = fields.Integer(
|
||||
string="HTTP Status",
|
||||
help="HTTP status code returned to the client.",
|
||||
)
|
||||
result_message = fields.Text(
|
||||
help="Message returned by the webhook code or authenticator (if any).",
|
||||
)
|
||||
error_message = fields.Text(
|
||||
help="Error message in case of authentication or code failure.",
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
comodel_name="res.users",
|
||||
string="Run as User",
|
||||
help="User as which the webhook code was executed (if set).",
|
||||
)
|
||||
ip_address = fields.Char(
|
||||
string="IP Address",
|
||||
help="IP address of the client that made the request.",
|
||||
)
|
||||
country_id = fields.Many2one(
|
||||
comodel_name="res.country",
|
||||
help="Country of the client that made the request.",
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute="_compute_display_name",
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
@api.depends("webhook_id", "endpoint", "http_status")
|
||||
def _compute_display_name(self):
|
||||
"""Compute display name."""
|
||||
for rec in self:
|
||||
rec.display_name = (
|
||||
f"{rec.webhook_id.display_name or ''} ({rec.endpoint}) "
|
||||
f"[{rec.http_status or ''}]"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_country_id(self):
|
||||
"""
|
||||
Return the country ID of the client based on geoip information.
|
||||
|
||||
Returns:
|
||||
int | bool: Country ID if found, otherwise False.
|
||||
"""
|
||||
country_code = None
|
||||
if request and hasattr(request, "geoip") and request.geoip:
|
||||
country_code = request.geoip.get("country_code")
|
||||
if country_code:
|
||||
country = (
|
||||
self.env["res.country"]
|
||||
.sudo()
|
||||
.search([("code", "=", country_code)], limit=1)
|
||||
)
|
||||
if country:
|
||||
return country.id
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _get_ip_address(self):
|
||||
"""
|
||||
Return the IP address of the client making the request.
|
||||
|
||||
Returns:
|
||||
str | None: IP address string, or None if unavailable.
|
||||
"""
|
||||
if not request:
|
||||
return None
|
||||
# Check for forwarded IP (common proxy headers)
|
||||
forwarded_for = request.httprequest.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# Return the first IP in the chain
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return request.httprequest.remote_addr
|
||||
|
||||
@api.model
|
||||
def create_from_call(self, **kwargs):
|
||||
"""
|
||||
Create a log entry from webhook call parameters.
|
||||
|
||||
Args:
|
||||
**kwargs: Values passed to `_prepare_values`.
|
||||
|
||||
Returns:
|
||||
CxTowerWebhookLog: Newly created log record.
|
||||
"""
|
||||
values = self._prepare_values(**kwargs)
|
||||
return self.create(values)
|
||||
|
||||
@api.model
|
||||
def _prepare_values(self, webhook=None, **kwargs):
|
||||
"""
|
||||
Prepare values for creating a webhook log record.
|
||||
|
||||
Args:
|
||||
webhook (RecordSet, optional): Webhook record.
|
||||
**kwargs: Additional fields such as endpoint, request_method, etc.
|
||||
|
||||
Returns:
|
||||
dict: Prepared values for log creation.
|
||||
"""
|
||||
vals = {
|
||||
"webhook_id": webhook.id if webhook else None,
|
||||
"endpoint": webhook.endpoint if webhook else kwargs.get("endpoint"),
|
||||
"authenticator_id": webhook.authenticator_id.id if webhook else None,
|
||||
"request_method": webhook.method
|
||||
if webhook
|
||||
else kwargs.get("request_method"),
|
||||
"user_id": webhook.user_id.id if webhook else None,
|
||||
"ip_address": self._get_ip_address(),
|
||||
"country_id": self._get_country_id(),
|
||||
**kwargs,
|
||||
}
|
||||
return vals
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_delete_old_logs(self):
|
||||
"""
|
||||
Remove old webhook log records beyond configured retention period.
|
||||
|
||||
This method is automatically triggered by Odoo's autovacuum.
|
||||
"""
|
||||
days = int(
|
||||
self.env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("cetmix_tower_webhook.webhook_log_duration", 30)
|
||||
)
|
||||
cutoff = fields.Datetime.now() - timedelta(days=days)
|
||||
logs_to_delete = self.sudo().search([("create_date", "<", cutoff)])
|
||||
logs_to_delete.unlink()
|
||||
13
addons/cetmix_tower_webhook/models/res_config_settings.py
Normal file
13
addons/cetmix_tower_webhook/models/res_config_settings.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
cetmix_tower_webhook_log_duration = fields.Integer(
|
||||
string="Keep Webhook Logs for (days)",
|
||||
help="Set the number of days to keep webhook logs. "
|
||||
"Old logs will be deleted automatically.",
|
||||
default=30,
|
||||
config_parameter="cetmix_tower_webhook.webhook_log_duration",
|
||||
)
|
||||
Reference in New Issue
Block a user