فهرست منبع

Closes #22212: Support for exposing environment parameters in Jinja template context (#22289)

Jeremy Stretch 1 روز پیش
والد
کامیت
4d8dbc6ffe
4فایلهای تغییر یافته به همراه101 افزوده شده و 2 حذف شده
  1. 30 0
      docs/configuration/system.md
  2. 43 1
      netbox/extras/tests/test_models.py
  3. 1 0
      netbox/netbox/settings.py
  4. 27 1
      netbox/utilities/jinja2.py

+ 30 - 0
docs/configuration/system.md

@@ -145,6 +145,24 @@ Set this configuration parameter to `True` for NetBox deployments which do not h
 
 ---
 
+## JINJA_ENVIRONMENT_PARAMS
+
+Default: `[]`
+
+A list of system environment variable names which may be referenced from within Jinja2 templates via the built-in [`env`](#jinja2_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example:
+
+```python
+JINJA_ENVIRONMENT_PARAMS = [
+    'WEBHOOK_TOKEN_*',
+    'DEFAULT_SECRET_ID',
+]
+```
+
+!!! info "Parameter names are case-sensitive"
+    For example, `FOO_*` will match `FOO_BAR` but `foo_*` will not.
+
+---
+
 ## JINJA2_FILTERS
 
 Default: `{}`
@@ -160,6 +178,18 @@ JINJA2_FILTERS = {
 }
 ```
 
+NetBox also registers the following filters by default. Any entry defined in `JINJA2_FILTERS` with the same name will override the default.
+
+| Filter | Description |
+|---|---|
+| `env` | Returns the value of the system environment variable with the given name, provided its name matches an entry in [`JINJA_ENVIRONMENT_PARAMS`](#jinja_environment_params). Returns `None` if the variable is not defined or its name is not whitelisted. |
+
+For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 template may reference an environment variable as:
+
+```
+Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }}
+```
+
 ---
 
 ## LOGGING

+ 43 - 1
netbox/extras/tests/test_models.py

@@ -31,7 +31,7 @@ from extras.models import (
 from extras.models.mixins import RenderTemplateMixin
 from tenancy.models import Tenant, TenantGroup
 from utilities.exceptions import AbortRequest
-from utilities.jinja2 import render_jinja2
+from utilities.jinja2 import env_filter, render_jinja2
 from utilities.tables import get_table_for_model
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
@@ -973,6 +973,48 @@ class ConfigTemplateDebugTestCase(TestCase):
             render_jinja2("{% debug %}", {}, debug=False)
 
 
+class JinjaEnvFilterTestCase(TestCase):
+    """
+    Tests for the env() Jinja2 filter and the JINJA_ENVIRONMENT_PARAMS configuration parameter.
+    """
+
+    def test_env_filter_returns_value_for_matching_name(self):
+        with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
+                self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
+            self.assertEqual(env_filter('NETBOX_TEST_TOKEN'), 'secret')
+
+    def test_env_filter_returns_none_for_unmatched_name(self):
+        with patch.dict('os.environ', {'NETBOX_OTHER_TOKEN': 'secret'}, clear=False), \
+                self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
+            self.assertIsNone(env_filter('NETBOX_OTHER_TOKEN'))
+
+    def test_env_filter_wildcard_match(self):
+        with patch.dict('os.environ', {'NETBOX_TEST_TOKEN_1': 'one', 'NETBOX_TEST_TOKEN_2': 'two'}, clear=False), \
+                self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN_*']):
+            self.assertEqual(env_filter('NETBOX_TEST_TOKEN_1'), 'one')
+            self.assertEqual(env_filter('NETBOX_TEST_TOKEN_2'), 'two')
+
+    def test_env_filter_returns_none_for_missing_env_var(self):
+        with self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_MISSING_VAR']):
+            self.assertIsNone(env_filter('NETBOX_MISSING_VAR'))
+
+    def test_env_filter_empty_whitelist_returns_none(self):
+        with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
+                self.settings(JINJA_ENVIRONMENT_PARAMS=[]):
+            self.assertIsNone(env_filter('NETBOX_TEST_TOKEN'))
+
+    def test_env_filter_registered_by_default(self):
+        with patch.dict('os.environ', {'NETBOX_TEST_TOKEN': 'secret'}, clear=False), \
+                self.settings(JINJA_ENVIRONMENT_PARAMS=['NETBOX_TEST_TOKEN']):
+            output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {})
+            self.assertEqual(output, 'secret')
+
+    def test_user_defined_filter_overrides_default(self):
+        with self.settings(JINJA2_FILTERS={'env': lambda name: 'overridden'}):
+            output = render_jinja2("{{ 'NETBOX_TEST_TOKEN' | env }}", {})
+            self.assertEqual(output, 'overridden')
+
+
 class ExportTemplateContextTestCase(TestCase):
     """
     Tests for ExportTemplate.get_context() including public model population.

+ 1 - 0
netbox/netbox/settings.py

@@ -140,6 +140,7 @@ HTTP_CLIENT_IP_HEADERS = getattr(configuration, 'HTTP_CLIENT_IP_HEADERS', (
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False)
+JINJA_ENVIRONMENT_PARAMS = getattr(configuration, 'JINJA_ENVIRONMENT_PARAMS', [])
 JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
 LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
 LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH

+ 27 - 1
netbox/utilities/jinja2.py

@@ -1,3 +1,6 @@
+import fnmatch
+import os
+
 from django.apps import apps
 from jinja2 import BaseLoader, TemplateNotFound
 from jinja2.meta import find_referenced_templates
@@ -6,11 +9,30 @@ from jinja2.sandbox import SandboxedEnvironment
 from netbox.config import get_config
 
 __all__ = (
+    'DEFAULT_JINJA2_FILTERS',
     'DataFileLoader',
+    'env_filter',
     'render_jinja2',
 )
 
 
+def env_filter(name):
+    """
+    Jinja2 filter which returns the value of an environment variable, provided its name matches one of the patterns
+    listed in the JINJA_ENVIRONMENT_PARAMS configuration parameter. Patterns may include wildcards. Returns None if the
+    variable is not defined or its name does not match an allowed pattern.
+    """
+    patterns = get_config().JINJA_ENVIRONMENT_PARAMS or []
+    if not any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns):
+        return None
+    return os.environ.get(name)
+
+
+DEFAULT_JINJA2_FILTERS = {
+    'env': env_filter,
+}
+
+
 class DataFileLoader(BaseLoader):
     """
     Custom Jinja2 loader to facilitate populating template content from DataFiles.
@@ -74,7 +96,11 @@ def render_jinja2(template_code, context, environment_params=None, data_file=Non
         environment_params['loader'] = loader
 
     environment = SandboxedEnvironment(**environment_params)
-    environment.filters.update(get_config().JINJA2_FILTERS)
+
+    # Register default filters, then apply any user-defined filters. User-defined entries take precedence so that
+    # existing JINJA2_FILTERS configurations are never overridden.
+    filters = {**DEFAULT_JINJA2_FILTERS, **get_config().JINJA2_FILTERS}
+    environment.filters.update(filters)
 
     if data_file:
         template = environment.get_template(data_file.path)