Skip to content

Enforce filter execution-context gate in infrahubctl render#957

Merged
gmazoyer merged 2 commits intoinfrahub-developfrom
fix/issue-955-infrahubctl-render-validate
Apr 22, 2026
Merged

Enforce filter execution-context gate in infrahubctl render#957
gmazoyer merged 2 commits intoinfrahub-developfrom
fix/issue-955-infrahubctl-render-validate

Conversation

@PhillSimonds
Copy link
Copy Markdown
Contributor

Why

infrahubctl render constructs a Jinja2Template and calls .render() without ever calling .validate(). Today that's accidentally safe — WORKER-only filters like artifact_content fail with "requires an InfrahubClient" because infrahubctl render doesn't pass one — but the moment a future change threads a client through (a natural step for iterative local transform development), those filters would silently execute in LOCAL context, bypassing the gate introduced in #889.

Goal: close the gap so that infrahubctl render enforces the ExecutionContext.LOCAL filter set regardless of whether a client is wired in.

Non-goals: not changing which filters are LOCAL-allowed, not adding new execution contexts, not modifying the worker or schema validation paths.

Closes #955

What changed

  • Behavioural change users observe: infrahubctl render on a template using a WORKER-only filter now surfaces "The 'artifact_content' filter isn't allowed to be used" (a proper filter-gating violation) instead of "requires an InfrahubClient". Missing filters that were previously caught at render time ("No filter named 'X'") are now caught at validate time with the standard filter-gating error.
  • Implementation: added the ExecutionContext import to cli_commands.py and a jinja_template.validate(context=ExecutionContext.LOCAL) call immediately before .render() inside the existing try/except.
  • Incidental bug fix in Jinja2Template.validate(): env.loader.get_source(...) raised raw jinja2.TemplateNotFound for missing file-based templates, which now escapes past the CLI's except JinjaTemplateError block. Wrapped it in JinjaTemplateNotFoundError to match render()'s behaviour. Added a unit test for the case. Without this, the existing main-template-not-found CLI test would break once validate() runs in the CLI path.
  • Tests: added a worker-only-filter-rejected-in-local-context test case to test_render_app.py backed by a new worker_only_filter.j2 fixture; updated the invalid-filter test to expect the new validate-time error message.
  • What stayed the same: the render pipeline itself, user-supplied filter allow-list semantics, restricted / context parameter API, and every other caller of Jinja2Template.validate().

How to review

  • infrahub_sdk/ctl/cli_commands.py: two lines — one import and one .validate() call. This is the core of the PR.
  • infrahub_sdk/template/__init__.py: a try/except around env.loader.get_source(...) in validate(). Worth a second read — it's a small bug fix in its own right (validate() should be symmetric with render() for missing-template handling).
  • tests/unit/ctl/test_render_app.py: updated invalid-filter error string + new worker-only-filter case.
  • tests/unit/sdk/test_template.py: one new test, test_validate_missing_file_raises_not_found.
  • tests/fixtures/repos/missing_template_file/: new worker_only_filter.j2 template and the corresponding .infrahub.yml entry.
  • changelog/955.fixed.md: towncrier fragment.

How to test

Direct infrahubctl render reproduction

This test exercises the actual fix path end-to-end without needing a running Infrahub stack. Create a minimal repo on disk:

mkdir -p /tmp/pr955-repo/templates
cd /tmp/pr955-repo

# Template that uses a WORKER-only filter — should be rejected in LOCAL context
cat > templates/worker_only.j2 <<'EOF'
{{ "some-storage-id" | artifact_content }}
EOF

# Template that uses LOCAL-allowed filters — should render cleanly
cat > templates/local_ok.j2 <<'EOF'
{% set parsed = payload | from_json %}
hostname {{ parsed.hostname }} mtu {{ parsed.mtu }}
EOF

# Minimal GraphQL query (infrahubctl render needs one, but we'll mock the response)
cat > templates/dummy.gql <<'EOF'
query Dummy {
  CoreStandardGroup { edges { node { id } } }
}
EOF

cat > .infrahub.yml <<'EOF'
---
queries:
  - name: dummy_q
    file_path: templates/dummy.gql
jinja2_transforms:
  - name: worker_only_t
    query: dummy_q
    template_path: templates/worker_only.j2
  - name: local_ok_t
    query: dummy_q
    template_path: templates/local_ok.j2
EOF

Before this PR, running infrahubctl render on the WORKER-only template surfaces a misleading error:

cd /tmp/pr955-repo
export INFRAHUB_ADDRESS=http://localhost:8000   # any reachable or unreachable Infrahub is fine
uv run infrahubctl render worker_only_t
# → "An error occurred while rendering the jinja template"
# → "Filter 'artifact_content': requires an InfrahubClient — pass a client via Jinja2Template(client=...)"

After this PR, the same command surfaces the correct security-gating error:

uv run infrahubctl render worker_only_t
# → "An error occurred while rendering the jinja template"
# → "The 'artifact_content' filter isn't allowed to be used"

The LOCAL-allowed template should render the same way before and after this PR (no regression on legitimate usage):

uv run infrahubctl render local_ok_t payload='{"hostname":"sw01","mtu":9000}'
# → "hostname sw01 mtu 9000"

Incidental fix — missing-template handling in validate()

Verify the fix to Jinja2Template.validate() handles missing file templates gracefully (matches render()):

cd /tmp/pr955-repo
uv run infrahubctl render tag_format_missing   # a transform whose template_path doesn't exist

# Before + after this PR: clean error message from the CLI rather than a raw Python traceback.
# Behaviour is preserved — without the incidental fix, adding validate() would have regressed this path.

Alternative direct API verification:

from pathlib import Path
from infrahub_sdk.template import Jinja2Template

j = Jinja2Template(template=Path("does-not-exist.j2"), template_directory=Path("/tmp"))

# Before this PR: raw jinja2.TemplateNotFound
# After this PR: JinjaTemplateNotFoundError with filename set
j.validate()

End-to-end sanity with a live Infrahub stack (optional)

If you want to verify that existing artifact generation on a running Infrahub instance is unaffected (this PR only touches the infrahubctl render CLI path, not the worker path):

  1. Register any repo with a Jinja2 transform that uses artifact_content against a known-good storage_id.
  2. Trigger artifact generation via POST /api/artifact/generate/<artifact_definition_id>.
  3. Confirm the artifact still generates successfully. The worker path goes through backend/infrahub/git/integrator.py:1215-1222, which already calls validate(context=CORE | WORKER) — unchanged by this PR.

Impact & rollout

  • Backward compatibility: the error message surfaced by infrahubctl render for templates using WORKER-only filters changes. This is an intentional clarification — the old message was misleading ("requires an InfrahubClient" implied you could fix it by passing one, which would actually bypass security). Any external tooling that asserts against the old string would need updating; unlikely to exist in practice.
  • Performance: none — validate() is O(number of filter nodes) parse.
  • Config/env changes: none.
  • Deployment notes: safe to deploy.

Checklist

  • Tests added/updated (new CLI test case, new validate() not-found test, updated invalid-filter expectation)
  • Changelog entry added (changelog/955.fixed.md)
  • External docs updated — no user-facing doc change; this enforces a security property that was already documented.
  • Internal .md docs updated — none needed.

render_jinja2_template in the CLI constructed a Jinja2Template and
called .render() without ever calling .validate(). Today that is
accidentally safe: WORKER-only filters like artifact_content fail with
"requires an InfrahubClient" because infrahubctl render does not pass
one. But the moment a future change threads a client through — a
natural thing to do to enable iterative transform development — those
filters would silently run locally, bypassing the WORKER-only gate.

Add jinja_template.validate(context=ExecutionContext.LOCAL) before
.render() so the context gate is enforced regardless of client state.

Incidental: Jinja2Template.validate() did not handle
jinja2.TemplateNotFound from env.loader.get_source() the way .render()
does — a missing file-based template raised raw TemplateNotFound that
escaped callers expecting JinjaTemplateError. Wrapped it in
JinjaTemplateNotFoundError to match render()'s behavior and added a
unit test for the case.

Added a fixture template using artifact_content plus a parametrised
CLI test asserting infrahubctl render rejects it with the expected
violation message. Updated the invalid-filter test: missing filters
are now caught at validate() time with the standard filter-gating
error message rather than at render time.

Fixes #955
@PhillSimonds PhillSimonds requested a review from a team as a code owner April 22, 2026 00:39
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

@@                 Coverage Diff                  @@
##           infrahub-develop     #957      +/-   ##
====================================================
+ Coverage             81.11%   81.24%   +0.13%     
====================================================
  Files                   134      134              
  Lines                 11314    11320       +6     
  Branches               1693     1693              
====================================================
+ Hits                   9177     9197      +20     
+ Misses                 1594     1581      -13     
+ Partials                543      542       -1     
Flag Coverage Δ
integration-tests 41.82% <16.66%> (-0.02%) ⬇️
python-3.10 54.15% <100.00%> (+0.12%) ⬆️
python-3.11 54.16% <100.00%> (+0.13%) ⬆️
python-3.12 54.15% <100.00%> (+0.13%) ⬆️
python-3.13 54.15% <100.00%> (+0.12%) ⬆️
python-3.14 55.75% <100.00%> (+0.13%) ⬆️
python-filler-3.12 22.78% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/ctl/cli_commands.py 72.33% <100.00%> (+0.22%) ⬆️
infrahub_sdk/template/__init__.py 95.33% <100.00%> (+0.77%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread tests/unit/sdk/test_template.py Outdated
Comment on lines +334 to +336
"""validate() on a non-existent file-based template must raise JinjaTemplateNotFoundError
so callers (e.g. infrahubctl render) can handle it the same way they handle it from render().
"""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The robot overdone that comment a bit. It's quite a simple test, the comment is too specific, you can remove it.

Comment thread changelog/955.fixed.md Outdated
@@ -0,0 +1 @@
Fixed `infrahubctl render` to enforce the Jinja2 filter execution-context gate. `render_jinja2_template` in the CLI now calls `Jinja2Template.validate(context=ExecutionContext.LOCAL)` before rendering, so attempts to use WORKER-only filters like `artifact_content` are rejected with `JinjaTemplateOperationViolationError` instead of relying on the side-effect that no `InfrahubClient` is wired through. This closes the gap where a future refactor threading a client into local render would have silently bypassed the WORKER-only restriction.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure we need the changelog entry since this is still unreleased code.

@gmazoyer gmazoyer linked an issue Apr 22, 2026 that may be closed by this pull request
The bug was introduced in the same release cycle, so a changelog
record would appear as a fix for something that was never released.
Also remove overly verbose test docstring per review feedback.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 22, 2026

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: c36311a
Status: ✅  Deploy successful!
Preview URL: https://5811e9eb.infrahub-sdk-python.pages.dev
Branch Preview URL: https://fix-issue-955-infrahubctl-re.infrahub-sdk-python.pages.dev

View logs

@gmazoyer gmazoyer force-pushed the fix/issue-955-infrahubctl-render-validate branch from 47f8f64 to c36311a Compare April 22, 2026 08:31
@gmazoyer gmazoyer merged commit 67a0061 into infrahub-develop Apr 22, 2026
20 checks passed
@gmazoyer gmazoyer deleted the fix/issue-955-infrahubctl-render-validate branch April 22, 2026 09:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: infrahubctl render skips Jinja2 filter context validation

2 participants