Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ jobs:
# pytest run cross-contaminates sys.modules. So run each in its own
# process with the inifile addopts (pytest-django flags) cleared.
# Excluded: test_integration (needs a live backend),
# test_audit_trail (pulls full app deps incl. psycopg),
# test_dynamic_survey_standalone (order-dependent Django stub).
for f in $(ls tests_standalone/test_*.py | grep -vE 'test_integration|test_audit_trail|test_dynamic_survey_standalone'); do
# test_audit_trail (pulls full app deps incl. psycopg).
for f in $(ls tests_standalone/test_*.py | grep -vE 'test_integration|test_audit_trail'); do
echo "::group::$f"
python -m pytest "$f" -o addopts="" -q
echo "::endgroup::"
Expand Down
100 changes: 82 additions & 18 deletions docs/13-dynamic-surveys.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ surveys resolve choices from three configurable sources:
| ------------------- | -------------------------------------------------- | ----------------------------------- |
| **Database Query** | Query Forail models (hosts, groups, projects, etc.) | Select a host from inventory |
| **External API** | Fetch choices from an HTTP endpoint | Options from CMDB, ServiceNow, etc. |
| **Jinja2 Template** | Evaluate a Jinja2 expression | Custom logic using inventory data |
| ~~Jinja2 Template~~ | **Withdrawn** — see below | — |

Results are cached with a configurable TTL to avoid slow launches.

Expand Down Expand Up @@ -60,7 +60,7 @@ A survey question with dynamic choices includes a `dynamic_choices` field:
| Field | Type | Required | Description |
| ------------- | ------- | ---------------- | -------------------------------------------- |
| `enabled` | boolean | Yes | Enable/disable dynamic choices |
| `source_type` | string | Yes (if enabled) | One of: `db_query`, `api_endpoint`, `jinja2` |
| `source_type` | string | Yes (if enabled) | One of: `db_query`, `api_endpoint` (`jinja2` is withdrawn) |
| `cache_ttl` | integer | No (default: 60) | Cache duration in seconds (0 = no cache) |

---
Expand Down Expand Up @@ -111,7 +111,38 @@ the system automatically filters by the job template's inventory.

## Source: External API

Fetch choices from an HTTP endpoint. Supports JSON responses.
Fetch choices from an HTTPS endpoint. Supports JSON responses.

> **The destination must be named by an administrator.** The server makes this
> request from inside the cluster, and the URL comes from the survey — so
> without a destination policy, whoever can edit a job template can point the
> server at cloud metadata, at a service bound to loopback, or at a neighbouring
> pod, and read the reply through the choices endpoint. Hosts are therefore
> listed in `SURVEY_DYNAMIC_CHOICES_API_ALLOWLIST` in the server settings file,
> matched exactly. The list is **empty by default, which disables this source
> type**.
>
> ```python
> # /etc/tower/conf.d/dynamic_surveys.py
> SURVEY_DYNAMIC_CHOICES_API_ALLOWLIST = ['cmdb.internal.example.com']
> ```
>
> Rules that apply even to a listed host:
>
> - **HTTPS only.**
> - The name is re-checked **after DNS resolution**. Private addresses are
> allowed — an on-prem CMDB on `10.0.0.0/8` is the ordinary case — but
> loopback, link-local (`169.254.0.0/16`, where cloud metadata lives),
> multicast and reserved addresses are refused. A listed name whose DNS answer
> points inward does not get through.
> - **Redirects are never followed.** The first hop is the one that was checked;
> every hop after it would be the peer's choice.
> - Methods are limited to `GET` and `POST`, `timeout` is capped at 30 seconds,
> and the response is read up to 1 MiB.
>
> A URL that is not permitted is rejected when the survey is **saved**, so the
> editor is told rather than left with a question that silently resolves to
> nothing.

```json
{
Expand All @@ -131,12 +162,12 @@ Fetch choices from an HTTP endpoint. Supports JSON responses.

| Field | Type | Default | Description |
| ------------- | ------- | ------- | ------------------------------------------ |
| `url` | string | — | HTTP endpoint URL (required) |
| `url` | string | — | HTTPS endpoint URL (required, host must be allowlisted) |
| `method` | string | `GET` | HTTP method (`GET` or `POST`) |
| `headers` | object | `{}` | Custom HTTP headers |
| `headers` | object | `{}` | Custom HTTP headers. **Stored in the survey spec in plaintext** — anyone who can read the job template can read them |
| `json_path` | string | `""` | Dot-notation path to the array in response |
| `value_field` | string | `""` | Field to extract from objects in the array |
| `timeout` | integer | `10` | Request timeout in seconds |
| `timeout` | integer | `10` | Request timeout in seconds (capped at 30) |
| `body` | object | `{}` | Request body for POST method |

### Response Formats
Expand Down Expand Up @@ -165,15 +196,35 @@ With `json_path: "data.items"` and `value_field: "hostname"`, this returns

---

## Source: Jinja2 Template
## Source: Jinja2 Template — withdrawn

Evaluate a Jinja2 expression that outputs a JSON array.
**This source type is disabled and surveys can no longer be saved with it.**

The template was rendered on the server, in the web process, whenever a user
with `start` permission opened the launch prompt. Jinja2 seeds every environment
with objects whose `__init__.__globals__` reaches Python's module table, so
whoever could edit a job template's survey could run arbitrary code as the web
process — `{{ cycler.__init__.__globals__.os.environ.get('PATH') }}` returned the
server's `PATH`. Earlier versions of this page claimed the templates ran in a
restricted sandbox. They did not.

Surveys already stored with `source_type: jinja2` resolve to **no choices** and
log a warning; they are not executed. Move them to `db_query` (for anything
drawn from inventory) or `api_endpoint` (for anything computed elsewhere).

An operator who accepts the risk can set
`SURVEY_DYNAMIC_CHOICES_JINJA2_ENABLED = True` in the server settings file — not
via `/api/v2/settings/`, deliberately, so the API used to store a template cannot
also enable its execution. Templates then render in a Jinja2 sandbox with globals
removed and a reduced filter set. Treat that as hardening, not as a boundary:
sandbox escapes are found periodically, and a template rendering in the web
process is worth an escape to whoever plants it.

```json
{
"enabled": true,
"source_type": "jinja2",
"template": "{{ groups | tojson }}",
"template": "{{ groups | sort | tojson }}",
"cache_ttl": 60
}
```
Expand All @@ -196,8 +247,8 @@ The template **must output a valid JSON array**.
{# Filter hosts by prefix #}
{{ hosts | select("match", "^web") | list | tojson }}

{# Static list generated from range #}
{{ range(1, 11) | list | tojson }}
{# `range` and the other Jinja globals are removed; build from context instead #}
{{ groups | sort | tojson }}
```

---
Expand Down Expand Up @@ -246,12 +297,17 @@ Requires `start` permission on the job template (same as launching).

1. `dynamic_choices` is only valid on `multiplechoice` and `multiselect` types
2. When `dynamic_choices.enabled` is `true`, static `choices` field is not required
3. During job launch, answers to dynamic choice questions skip static choice validation
4. The `source_type` must be one of: `db_query`, `api_endpoint`, `jinja2`
3. During job launch, an answer to a dynamic-choices question is checked against
the **resolved** list, not against the static `choices` field. If the source
cannot be resolved the list is empty and every answer is rejected — an answer
that cannot be validated is not accepted
4. The `source_type` must be one of: `db_query`, `api_endpoint`. `jinja2` is
rejected unless `SURVEY_DYNAMIC_CHOICES_JINJA2_ENABLED` is `True` in the
server settings file
5. DB query `model` must be from the allowed list
6. DB query `field` must be from: `name`, `id`, `description`
7. API endpoint requires a non-empty `url`
8. Jinja2 requires a non-empty `template`
8. Jinja2, where an operator has re-enabled it, requires a non-empty `template`
9. `cache_ttl` must be a non-negative integer

---
Expand All @@ -271,16 +327,24 @@ Requires `start` permission on the job template (same as launching).
## Caching

- Resolved choices are cached in Django's cache backend (Redis)
- Cache key includes the variable name and full source configuration hash
- The cache key covers the question, the source configuration, **and the scope
the answer was resolved in** — job template, organization and inventory. It
used to be the variable name plus a config hash alone, so two templates with
the same question and different inventories shared one entry and the second
caller was served the first one's host names
- Nothing is cached when there is no template to scope by
- Default TTL: 60 seconds
- Set `cache_ttl: 0` to disable caching
- Cache is shared across all users and launch requests
- Within one scope, the cache is shared across users

---

## Limitations

- Maximum 500 choices returned per question (to prevent UI issues)
- Jinja2 templates run in a restricted sandbox (no file I/O)
- Maximum 500 choices returned per question (to prevent UI issues), applied to
every source type
- Jinja2 as a source type is withdrawn; where an operator has re-enabled it,
templates render in a Jinja2 sandbox with globals removed and a reduced filter
set — hardening, not a trust boundary
- External API requests have a configurable timeout (default 10s)
- DB query filters are limited to safe field lookups for security
41 changes: 20 additions & 21 deletions forail/main/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,30 +279,29 @@ def _survey_element_validation(self, survey_element, data, validate_required=Tru
if type(data[survey_element['variable']]) != list:
errors.append("'%s' value is expected to be a list." % survey_element['variable'])
else:
# Skip static choice validation for dynamic choices questions
dc = survey_element.get('dynamic_choices', {})
if dc and dc.get('enabled'):
pass # Dynamic choices are validated at resolve time
else:
choice_list = copy(survey_element['choices'])
if isinstance(choice_list, str):
choice_list = [choice for choice in choice_list.splitlines() if choice.strip() != '']
for val in data[survey_element['variable']]:
if val not in choice_list:
errors.append("Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'], choice_list))
choice_list, is_dynamic = self._survey_choice_list(survey_element)
for val in data[survey_element['variable']]:
if val not in choice_list:
errors.append(self._choice_error(survey_element, val, choice_list, is_dynamic))
elif survey_element['type'] == 'multiplechoice':
dc = survey_element.get('dynamic_choices', {})
if dc and dc.get('enabled'):
pass # Dynamic choices are validated at resolve time
else:
choice_list = copy(survey_element['choices'])
if isinstance(choice_list, str):
choice_list = [choice for choice in choice_list.splitlines() if choice.strip() != '']
if survey_element['variable'] in data:
if data[survey_element['variable']] not in choice_list:
errors.append("Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']], survey_element['variable'], choice_list))
choice_list, is_dynamic = self._survey_choice_list(survey_element)
if survey_element['variable'] in data:
if data[survey_element['variable']] not in choice_list:
errors.append(self._choice_error(survey_element, data[survey_element['variable']], choice_list, is_dynamic))
return errors

def _survey_choice_list(self, survey_element):
"""Permitted values for a choice question; see the service for the why."""
from forail.main.services.dynamic_survey import survey_choice_list

return survey_choice_list(survey_element, template=self)

@staticmethod
def _choice_error(survey_element, value, choice_list, is_dynamic):
from forail.main.services.dynamic_survey import choice_error_message

return choice_error_message(survey_element, value, choice_list, is_dynamic)

def _accept_or_ignore_variables(self, data, errors=None, _exclude_errors=(), extra_passwords=None):
survey_is_enabled = self.survey_enabled and self.survey_spec
extra_vars = data.copy()
Expand Down
Loading
Loading