Merge main into feature

This commit is contained in:
Jeremy Stretch 2026-07-21 09:09:58 -04:00
parent cfbbceea4d
commit e50683fee9
176 changed files with 32639 additions and 7872 deletions

328
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,328 @@
name: Build and publish Python package
# Least-privilege default for every job; the publish job grants itself id-token below.
permissions:
contents: read
on:
pull_request:
paths:
- '.github/workflows/release.yml'
- 'pyproject.toml'
- 'README.md'
- 'LICENSE.txt'
- 'base_requirements.txt'
- 'requirements.txt'
- 'upgrade.sh'
- 'contrib/**'
- 'docs/**'
- 'mkdocs.yml'
- 'netbox/**'
- 'scripts/packaging/**'
- 'scripts/verify_*.py'
- 'scripts/smoketest_configuration.py'
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
name: Build package artifacts
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Install build tooling
run: python -m pip install --upgrade build twine
- name: Install documentation toolchain
run: python -m pip install -r requirements.txt
- name: Render the documentation
# -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
# additionally guards against a partial render reaching the wheel.
run: zensical build -c -s
- name: Build sdist and wheel
run: python -m build
- name: Check package metadata
run: twine check dist/*
- name: Upload package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-package-distributions
path: dist/
if-no-files-found: error
verify-dependencies:
name: Verify dependency pins are in sync
runs-on: ubuntu-latest
needs: build
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Install packaging
run: python -m pip install packaging
- name: Verify requirements.txt is consistent with base_requirements.txt
run: python scripts/verify_dependencies.py
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify wheel Requires-Dist matches requirements.txt
run: python scripts/verify_wheel_metadata.py dist/*.whl
- name: Verify wheel excludes live configuration files
run: python scripts/verify_wheel_contents.py dist/*.whl
verify-sdist:
name: Verify the sdist builds a wheel
runs-on: ubuntu-latest
needs: build
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Install tooling
run: python -m pip install --upgrade pip packaging
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify the sdist contents
run: |
python scripts/verify_sdist_contents.py dist/*.tar.gz
- name: Build a wheel from the sdist
run: |
python -m pip wheel --no-deps dist/*.tar.gz -w sdist-wheel/
- name: Verify the sdist-built wheel
run: |
python scripts/verify_wheel_metadata.py sdist-wheel/*.whl
python scripts/verify_wheel_contents.py sdist-wheel/*.whl
cli-smoke-test:
name: Smoke test wheel CLI (no dependencies)
runs-on: ubuntu-latest
needs: build
# The pre-configuration CLI paths are stdlib-only, so a --no-deps install suffices.
# Unlike smoke-test, this job also runs on pull requests.
steps:
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Install wheel without dependencies
run: |
python -m venv "$RUNNER_TEMP/netbox-cli-venv"
"$RUNNER_TEMP/netbox-cli-venv/bin/python" -m pip install --no-deps dist/*.whl
- name: Exercise the pre-configuration CLI
run: |
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" --version
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" version
"$RUNNER_TEMP/netbox-cli-venv/bin/python" -m netbox --version
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
- name: Smoke-test netbox setup from the wheel
run: |
"$RUNNER_TEMP/netbox-cli-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
for f in "$RUNNER_TEMP/nbroot/conf/__init__.py" "$RUNNER_TEMP/nbroot/conf/configuration.py" "$RUNNER_TEMP/nbroot/local_requirements.txt"; do
test -f "$f" || { echo "missing $f"; exit 1; }
done
for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
test -s "$RUNNER_TEMP/nbroot/contrib/$f" || { echo "missing or empty contrib/$f"; exit 1; }
done
smoke-test:
name: Smoke test wheel install
runs-on: ubuntu-latest
needs: build
# The wheel install + database migration is expensive; only run it for tag
# pushes and manual dispatch, not on every packaging-related pull request.
# cli-smoke-test provides lightweight, dependency-free CLI coverage on every PR instead.
if: github.event_name != 'pull_request'
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U netbox -d netbox"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NETBOX_CONFIGURATION: smoketest_configuration
POSTGRES_DB: netbox
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
cache: pip
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Install system build dependencies for psycopg
run: sudo apt-get update && sudo apt-get install -y libpq-dev
- name: Install wheel into a clean virtual environment
run: |
python -m venv "$RUNNER_TEMP/netbox-wheel-venv"
"$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install --upgrade pip
"$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install dist/*.whl
- name: Run NetBox smoke checks
env:
# STATIC_ROOT is not a configuration parameter; NETBOX_ROOT places it under the scratch base.
NETBOX_ROOT: ${{ runner.temp }}/netbox-smoketest
NETBOX_SMOKETEST_BASE: ${{ runner.temp }}/netbox-smoketest
PYTHONPATH: ${{ github.workspace }}/scripts
run: |
"$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" check
"$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" upgrade --no-input
test -f "$NETBOX_SMOKETEST_BASE/static/docs/index.html" || { echo "bundled documentation was not collected to STATIC_ROOT"; exit 1; }
test -f "$NETBOX_SMOKETEST_BASE/static/docs/models/dcim/device/index.html" || { echo "model documentation page was not collected"; exit 1; }
- name: Smoke-test netbox setup from the wheel
run: |
"$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
diff -q "$RUNNER_TEMP/nbroot/conf/configuration.py" netbox/netbox/configuration_example.py
for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
diff -q "$RUNNER_TEMP/nbroot/contrib/$f" "contrib/$f"
done
publish-testpypi:
name: Publish package to Test PyPI
runs-on: ubuntu-latest
needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
# Publishing always requires a v* tag ref: a tag push publishes to Test PyPI
# automatically, and a manual dispatch does the same when the chosen ref is a v* tag.
# Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run)
# but the publish job is skipped. Production PyPI publishing is intentionally absent
# during the v4.6.x preview; it arrives with the v4.7.0 feature branch.
# startsWith() only routes to this job (workflow `if:` expressions cannot regex-match);
# the exact tag format (v<release.yaml version>) is enforced below by the "Enforce
# release tag format" step and scripts/verify_release_tag.py before any upload.
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
environment:
name: testpypi
url: https://test.pypi.org/p/netbox
permissions:
contents: read
id-token: write
steps:
- name: Enforce release tag format
env:
TAG: ${{ github.ref_name }}
run: |
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
exit 1
}
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install tooling
run: python -m pip install --upgrade pip packaging
- name: Download package artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-package-distributions
path: dist/
- name: Verify the git tag matches the built version
run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl
- name: Publish package distributions to Test PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
repository-url: https://test.pypi.org/legacy/

5
.gitignore vendored
View File

@ -64,3 +64,8 @@ yarn-error.log*
.idea/
.vscode/
.python-version
# Python package build artifacts
/dist/
/build/
*.egg-info/

View File

@ -5,7 +5,7 @@
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-16-blue" alt="Languages supported" /></a>
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-17-blue" alt="Languages supported" /></a>
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
<p>
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
@ -20,6 +20,7 @@ NetBox exists to empower network engineers. Since its release in 2016, it has be
<a href="#netboxs-role">NetBox's Role</a> |
<a href="#why-netbox">Why NetBox?</a> |
<a href="#getting-started">Getting Started</a> |
<a href="#plugins">Plugins</a> |
<a href="#get-involved">Get Involved</a> |
<a href="#screenshots">Screenshots</a>
</p>
@ -85,6 +86,16 @@ NetBox automatically logs the creation, modification, and deletion of all manage
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
## Plugins
NetBox's functionality can be extended through plugins, which add new models, views, and integrations on top of the core application. A few of the most popular plugins include:
* [NetBox Branching](https://github.com/netboxlabs/netbox-branching) — Work with isolated, mergeable branches of your NetBox data
* [NetBox Custom Objects](https://github.com/netboxlabs/netbox-custom-objects) — Define entirely new object types directly in the UI
* [NetBox DNS](https://github.com/sys4/netbox-plugin-dns) — Manage DNS zones and records as an authoritative source of truth
* [NetBox BGP](https://github.com/netbox-community/netbox-bgp) — Document and manage BGP sessions and routing policies
* [Browse all plugins](https://netboxlabs.com/plugins/) — Discover the full catalog of available plugins
## Get Involved
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!

View File

@ -22,6 +22,8 @@ If you would like to consider upgrading to NetBox Cloud or Enterprise, please co
## Reporting a Suspected Vulnerability
Before reporting, please review our [Threat Model](THREAT_MODEL.md) to confirm that the behavior you've observed is an in-scope vulnerability and not an intended, privileged operation.
If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so by emailing `security@netboxlabs.com`. Please ensure that your report meets all the following conditions:
* Affects the most recent stable release of NetBox, or a current beta release

133
THREAT_MODEL.md Normal file
View File

@ -0,0 +1,133 @@
# NetBox Threat Model
## Purpose & Scope
This document describes the security threat model for **NetBox Community Edition**, installed and operated according to the [official documentation](https://netboxlabs.com/docs/netbox/). Its purpose is to state explicitly who and what NetBox trusts, what a supported deployment looks like, and — most importantly — which classes of behavior are intended, privileged operations rather than security vulnerabilities.
NetBox is a feature-rich application that deliberately grants powerful capabilities (code execution, template rendering, outbound requests) to privileged users in order to support advanced network automation workflows. Many security reports we receive describe these intended capabilities as if they were defects. This document exists so that prospective reporters — and the maintainers who triage their reports — can quickly distinguish a genuine vulnerability from an authorized, privileged operation working as designed.
This document **complements** our [Security Policy](SECURITY.md); it does not replace it. The policy governs *how* to report a suspected vulnerability and the conditions a report must meet. This document governs *what* constitutes a vulnerability in the first place.
This model anchors to [OWASP's threat modeling guidance](https://owasp.org/www-community/Threat_Modeling) and uses a lightweight [STRIDE](https://en.wikipedia.org/wiki/STRIDE_%28security%29) breakdown (see below).
## Supported Deployment Model
NetBox's threat model assumes a deployment consistent with the recommendations in our [Security Policy](SECURITY.md) and [installation documentation](https://netboxlabs.com/docs/netbox/installation/):
* **Not exposed to the public Internet.** NetBox is intended to run on an internal or otherwise access-controlled network, behind a reverse proxy (e.g. nginx). It is not designed or hardened to serve as an anonymous, public-facing web application.
* **Administered by trusted operators.** The individuals who deploy, configure, and administer NetBox — including holders of the `is_superuser` flag and anyone with shell, filesystem, or database access to the host — are assumed to be trusted system administrators.
* **The database is reachable only by the application.** PostgreSQL and Redis are assumed to be accessible only to the NetBox application itself, not to arbitrary clients.
* **An authenticated user base.** NetBox is intended for use only by authenticated users. [`LOGIN_REQUIRED`](https://netboxlabs.com/docs/netbox/configuration/security/#login_required) defaults to `True`, and support for unauthenticated access is being removed entirely in NetBox v5.0.
* **The reverse proxy owns the network edge.** TLS termination, HTTP request rate limiting, and authoritative determination of the client IP address are the responsibility of the deployment's reverse proxy and surrounding infrastructure — not the application. (See [`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers); the headers NetBox trusts for client IP are only as trustworthy as the proxy that sets them.)
Reports that assume a deployment outside this model — for example, "an anonymous Internet user can reach the login page" or "an administrator can modify the database" — describe the intended operating environment, not a vulnerability.
## Trusted vs. Untrusted Actors
The central question when evaluating any NetBox security report is: **does the attack require a privilege that NetBox already designates as trusted?**
| Actor | Trust | Notes |
| --- | --- | --- |
| The NetBox server / process | **Trusted** | Executes application code; holds secrets. |
| PostgreSQL database, Redis | **Trusted** | Assumed reachable only by the application. |
| Infrastructure operators | **Trusted** | Shell/filesystem/DB access implies total control by design. |
| Superusers (`is_superuser`) | **Trusted** | An active superuser bypasses all object-level permission checks. This is intentional. |
| Users permitted to author code-bearing objects | **Trusted** | Holders of permissions to create/modify custom scripts, export templates, config templates, custom links, or webhooks (see below). |
| Authenticated users **without** those permissions | **Untrusted** | Subject to full object-based permission enforcement. |
| Unauthenticated / network-adjacent parties | **Untrusted** | Outside the supported deployment model entirely. |
The governing principle:
> **Granting a user permission to author a custom script, export or config template, custom link, or webhook is equivalent to granting that user a degree of code execution — by design.** Abuse of such a feature by a user who holds the corresponding permission is not a vulnerability. The mitigation is administrative: grant these permissions only to trusted users, as instructed by the documentation for each feature.
## Privileged-by-Design Features
The following features deliberately allow trusted users to supply code or logic that NetBox executes or renders. Each is gated by a specific permission and carries an explicit warning in its documentation. Using these features as designed — even in ways that read like "code execution" or "data access" to an outside observer — is **not** a vulnerability.
### Custom Scripts
Custom scripts are Python modules with **unrestricted access to the NetBox ORM, database, and Python runtime**. They are gated by the `extras.run_script` permission (and authored by users who can add/modify script modules). The documentation states plainly that they are *"inherently unsafe and should be installed and run only from trusted sources"*.
### Export Templates, Config Templates, Custom Links & Webhooks (Jinja)
These features render **user-authored [Jinja templates](https://jinja.palletsprojects.com/en/stable/)** with live application objects in scope. Templates are evaluated in a Jinja [`SandboxedEnvironment`](https://jinja.palletsprojects.com/en/stable/sandbox/) (`netbox/utilities/Jinja.py`), which restricts access to unsafe attributes and operations.
It is important to be precise about where the boundary lies:
* The sandbox **is** a boundary NetBox maintains. A genuine, reproducible *escape* from the sandbox — code or attribute access the sandbox is supposed to block — **is** a vulnerability we take seriously (see "In-Scope Vulnerabilities").
* Authoring these objects is nonetheless a **privileged action**. A template author legitimately has broad read access to NetBox objects and can produce arbitrary output within the sandbox's bounds. That a template can read data the author is otherwise permitted to see, or generate HTML/configuration, is intended behavior — not an injection vulnerability.
Each feature's documentation states that the relevant permission should be granted only to trusted users:
* [Export templates](https://netboxlabs.com/docs/netbox/customization/export-templates/)
* [Custom links](https://netboxlabs.com/docs/netbox/customization/custom-links/)
* [Webhooks](https://netboxlabs.com/docs/netbox/integrations/webhooks/)
* [Configuration rendering](https://netboxlabs.com/docs/netbox/features/configuration-rendering)
### Webhooks & Event Rules (Outbound Requests)
Webhooks issue **outbound HTTP requests to operator-defined URLs**, with the URL, headers, and body all rendered from user-authored Jinja. A trusted webhook author can therefore direct requests to arbitrary endpoints. This server-side request capability is the entire purpose of the feature; it is available only to users permitted to create webhooks, and is not a server-side request forgery (SSRF) vulnerability when exercised by such a user.
### Config Contexts & Custom Fields
Config contexts store arbitrary JSON applied to devices and virtual machines; custom fields add operator-defined attributes (with optional regex/JSON-schema validation). Neither executes code directly. Config context data may, however, be consumed by config templates during rendering, so it inherits the same "template author is trusted" posture described above.
### Object-Based Permissions
NetBox enforces a robust [object-based permission system](https://netboxlabs.com/docs/netbox/features/authentication-permissions/) layered on top of Django's model permissions. Permissions combine object types, users/groups, actions, and optional JSON **constraints** (including the special `$user` token). A failure of this system to enforce a permission or constraint that it advertises **is** a vulnerability (see below).
## In-Scope Vulnerabilities
We take the following seriously. The common thread is a breach of a boundary NetBox *claims* to enforce, or harm to a user who never consented to the risk.
* **Authorization bypass** — reading or acting on objects a user has no permission to access.
* **Privilege escalation** — bypassing a permission or constraint to gain access beyond what was granted.
* **Injection that crosses a data boundary** — e.g. filter/ORM operator injection in the REST or GraphQL API exposing data a user shouldn't reach.
* **Cross-site scripting (XSS) against a non-consenting victim** — stored or DOM-based XSS that executes in another user's session.
* **Jinja sandbox escapes** — a reproducible escape from the template sandbox's intended restrictions.
* **Authentication bypass** and **unauthenticated remote code execution or data access**.
* **Dependency vulnerabilities with a realistic exploit path** through NetBox (not merely a flagged version).
## Out-of-Scope / Non-Issues
The following are **not** treated as NetBox vulnerabilities. Most describe a privileged feature used by a user the documentation already designates as trusted, or a concern that belongs to the deployment/platform layer.
| Scenario | Status | Reason |
| --- | --- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| A permitted user runs code via a custom script, Jinja template, custom link, or webhook | Not a vulnerability | These features exist to execute user-authored logic; the required permission is trusted, operator-tier by design. |
| A superuser modifies the database, reads secrets, or creates another superuser | Not a vulnerability | Superusers and infrastructure operators are trusted by design. |
| A template author reads NetBox data they are otherwise permitted to view | Not a vulnerability | Template rendering with objects in scope is the purpose of the feature; the sandbox, not permission scoping, is the boundary. |
| Missing login/request rate limiting | Out of scope | A deployment-layer concern, handled by the reverse proxy rather than the application. |
| Client IP spoofing via `X-Forwarded-For` and similar headers | Out of scope | NetBox trusts the headers the reverse proxy sets; trustworthy client IP is a proxy responsibility ([`HTTP_CLIENT_IP_HEADERS`](https://netboxlabs.com/docs/netbox/configuration/system/#http_client_ip_headers)). |
| Self-XSS (a user injecting script into their own session) | Not a vulnerability | The user is attacking only themselves; no privilege boundary is crossed. |
| CSRF on the login form | Not a vulnerability | Login CSRF is not a meaningful attack in NetBox's deployment model. |
| Automated-scanner reports that a file *may* be vulnerable | Rejected | Per our [Security Policy](SECURITY.md), we do not accept reports from automated tooling that merely suggest potential vulnerability without a confirmed reproducible exploit. |
## Lightweight STRIDE View
| Category | NetBox posture |
| --- | --- |
| **S**poofing | Authentication via local accounts, LDAP, or SSO (python-social-auth); API tokens. Authoritative client-IP determination is delegated to the reverse proxy. |
| **T**ampering | All writes are gated by object-based permissions with optional constraints, validated within atomic transactions. Code-bearing objects are writable only by trusted users. |
| **R**epudiation | Changes are recorded via the changelog and journaling; event rules can emit notifications. |
| **I**nformation disclosure | Object-based view permissions filter every queryset. Cross-boundary disclosure (e.g. API/GraphQL filter injection) is in scope; data legitimately visible to a template author is not. |
| **D**enial of service | Request rate limiting and resource controls are a deployment/reverse-proxy responsibility, not the application's. |
| **E**levation of privilege | The superuser flag is all-or-nothing and trusted. Any *unintended* escalation across the permission system (constraint bypass, action bypass) is in scope. |
## Triage & Severity
When triaging a report we assess the **CVSS environmental score**, not solely the base score. A finding with a high CVSS base score may be downgraded substantially once NetBox's deployment assumptions and trust boundaries are applied — for example, a "remote code execution" that in fact requires a permission we already designate as trusted (script or template authoring) is mitigated by design rather than by a code change.
We use [CVSS v3.1/v4.0](https://www.first.org/cvss/) for scoring and the [STRIDE](https://en.wikipedia.org/wiki/STRIDE_%28security%29) categories above to reason about boundaries. If you believe you have a fix that closes an in-scope issue without degrading the affected feature, you are welcome to propose it alongside your report.
## Reporting
Before reporting, please confirm that the behavior you've observed is an in-scope vulnerability under this document and not an intended, privileged operation, and that it is reproducible in the current stable release of NetBox.
To report a suspected vulnerability, follow the process in our [Security Policy](SECURITY.md). In summary, a report must:
* Affect the most recent stable release of NetBox, or a current beta release;
* Affect a NetBox instance installed and configured per the official documentation; and
* Be reproducible following a prescribed set of instructions.
Confidential reports may be sent to `security@netboxlabs.com`.

View File

@ -625,6 +625,7 @@
"st",
"cs",
"sn",
"mdc",
"sma-905",
"sma-906",
"urm-p2",
@ -697,6 +698,7 @@
"st",
"cs",
"sn",
"mdc",
"sma-905",
"sma-906",
"urm-p2",

3
contrib/netbox.env Normal file
View File

@ -0,0 +1,3 @@
# Optional overrides for a pip-installed NetBox. Do not put secrets here.
# NetBox loads conf/configuration.py from NETBOX_ROOT automatically.
NETBOX_ROOT=/opt/netbox

File diff suppressed because it is too large Load Diff

View File

@ -146,6 +146,9 @@ REDIS = {
It is highly recommended to keep the task and cache databases separate. Using the same database number on the
same Redis instance for both may result in queued background tasks being lost during cache flushing events.
!!! danger "Redis is a trusted component"
NetBox's background workers deserialize and execute jobs read from the `tasks` Redis database, so any party with write access to it can run arbitrary code on a worker. Redis must be treated as trusted infrastructure, on par with the PostgreSQL database: keep it bound to a private network and require authentication.
### UNIX Socket Support
Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example:

View File

@ -98,6 +98,9 @@ An ordered list of HTTP request headers inspected to determine the source IP add
The client IP is used for source-address restrictions on API tokens and for logging failed login attempts.
!!! warning "Client IP trust"
The headers listed here are trusted as the source of the client IP address. Trusting `X-Forwarded-For` (`HTTP_X_FORWARDED_FOR`) or `X-Real-IP` (`HTTP_X_REAL_IP`) is safe only when NetBox is deployed behind a reverse proxy that overwrites these headers with the real client address. If NetBox is reachable directly, or the proxy appends to or passes through a client-supplied value (NetBox uses the leftmost address, which the client controls when the proxy appends), a client can spoof its apparent IP address and defeat API token client IP restrictions. Deployments without a trusted proxy should set `HTTP_CLIENT_IP_HEADERS = ('REMOTE_ADDR',)`.
---
## HTTP_PROXIES

View File

@ -443,6 +443,18 @@ curl -X POST \
http://netbox/api/extras/scripts/upload/
```
### Updating an Uploaded Script
An existing script module can be replaced in place by sending a `multipart/form-data` PUT or PATCH request to the module's detail URL. The module may be identified by its numeric ID or by its file name. The uploaded file name must match the existing module's file path, and the caller must have the `extras.change_scriptmodule` and `core.change_managedfile` permissions. The module's scripts are re-synchronized from the new content.
```no-highlight
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json; indent=4" \
-F "file=@/path/to/myscript.py" \
http://netbox/api/extras/scripts/upload/myscript.py/
```
## Running Custom Scripts
!!! note

View File

@ -0,0 +1,126 @@
# Building the Package
NetBox package artifacts (a wheel and a source distribution) can be built and verified locally. During the v4.6.x preview period, published artifacts are for maintainer validation only. Installing NetBox via pip is not a supported installation path yet. Experimental support for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for maintainers and contributors working on the packaging itself; routine development does not require building a package.
The artifacts are always built by CI from a clean checkout (see `.github/workflows/release.yml`). A local build is useful for testing packaging changes before they are merged.
## Prerequisites
Install the minimum local build tooling (all three are also included in the `dev` optional dependency group):
```no-highlight
python -m pip install --upgrade build packaging twine
```
Building also requires a freshly rendered copy of the documentation site (see [Building](#building) below). The documentation toolchain (`zensical`, `mkdocs`, `mkdocs-material`, `mkdocstrings`) is pinned in `requirements.txt` rather than the `dev` group because it is also needed outside packaging, such as documentation previews and CI's `docs` job.
## Building
Render the documentation site at the repository root before building; both the wheel and the sdist bundle the rendered output, and the release workflow's `build` job renders in the same way:
```no-highlight
python -m pip install -r requirements.txt
zensical build -c -s
```
Always render with `-c` (clean cache) and `-s` (strict mode, abort on warnings) so a stale cache or a degraded build cannot slip into the artifacts. This writes `netbox/project-static/docs/` (gitignored). Building without a prior render fails because the rendered docs directory is a required Hatch force-include: Hatchling raises `FileNotFoundError: Forced include not found` for the missing directory. A render that exits successfully but produces a partial site is caught by `scripts/verify_wheel_contents.py`, which requires both the site root (`index.html`) and a model documentation page (`models/dcim/device/index.html`) in the wheel.
Build both the source distribution (sdist) and the wheel into `dist/`:
```no-highlight
python -m build
```
To build only the wheel (faster, and the form most useful for a quick local install test):
```no-highlight
python -m build --wheel
```
The package version and the wheel's runtime dependency metadata are both computed at build time by a Hatchling hook; see [Dynamic metadata](#dynamic-metadata) below.
## Clean-tree caveat
Always build release artifacts from a clean checkout. The build configuration keeps deployment-local files out of the artifacts: the Hatch excludes drop every `configuration*.py` and `ldap_config*.py` except the two tracked configuration templates (`configuration_example.py` and `configuration_testing.py`, which are force-included explicitly), and CI verifies the contents of both the wheel and the sdist before anything is published.
These checks are defense in depth, not a license to build from a dirty tree: other untracked files under `netbox/` can still be picked up by a local build. CI builds from a clean checkout, so the published artifacts are unaffected. For a comparable local build, use a fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
## Verifying
Check the built artifacts for valid package metadata and README rendering:
```no-highlight
twine check dist/*
```
Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the pinned `requirements.txt`, and the declared optional-dependency groups:
```no-highlight
python scripts/verify_wheel_metadata.py dist/*.whl
```
Confirm the artifacts ship only the two tracked configuration templates, and that the wheel carries the runtime-critical bundled data: `_data/release.yaml`, templates, translations, static assets, and the pre-rendered documentation site under `_data/docs/`. These are the same content checks CI runs before publishing:
```no-highlight
python scripts/verify_wheel_contents.py dist/*.whl
python scripts/verify_sdist_contents.py dist/*.tar.gz
```
Confirm `requirements.txt` is still consistent with the maintainer policy in `base_requirements.txt` (the same drift guard CI runs before publishing):
```no-highlight
python scripts/verify_dependencies.py
```
## Test-installing the wheel
Install the wheel into a throwaway virtual environment and run the system checks to confirm the package is importable and runnable:
```no-highlight
python -m venv /tmp/netbox-build-test
/tmp/netbox-build-test/bin/python -m pip install --upgrade pip
/tmp/netbox-build-test/bin/python -m pip install dist/*.whl
PYTHONPATH=$PWD/scripts \
NETBOX_CONFIGURATION=smoketest_configuration \
NETBOX_ROOT=/tmp/netbox-build-test-root \
NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \
/tmp/netbox-build-test/bin/netbox check
```
Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py` (default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development workstation. The environment variables above point `netbox check` at the same minimal configuration module used by the release workflow's smoke-test job (`scripts/smoketest_configuration.py`); run the command from the repository root so `PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under which the module creates its media, reports, and scripts roots; `NETBOX_ROOT` points the fixed collected-static root at the same directory. Any other importable configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the configuration lives outside the package). To exercise the full post-install task sequence from the wheel, run `netbox upgrade --no-input` with the same environment against a throwaway database (the collected static files land under `$NETBOX_ROOT/static`); this is what the release workflow's smoke-test job does. The documentation ships pre-rendered in the wheel, so there is nothing to build on the instance; `--build-docs` remains a checkout-only convenience for rendering the documentation from its sources.
## Packaging architecture
This section is a developer-facing overview of how the package is assembled and how a pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0); this page does not cover end-user installation steps.
### Dynamic metadata
`scripts/packaging/hatch_metadata.py` is a Hatchling metadata hook (wired in via `[tool.hatch.metadata.hooks.custom]`). It computes the package version from `netbox/release.yaml` and the runtime dependencies from the pinned `requirements.txt`, so the published wheel's `Requires-Dist` carries the exact versions NetBox is tested against. Both fields are declared `dynamic` in `pyproject.toml`; the optional-dependency extras stay static.
### sdist and the sdist-to-wheel guard
`python -m build` produces both an sdist and a wheel, with the wheel built from the sdist. The release workflow's `verify-sdist` job rebuilds a wheel from the candidate sdist and runs `scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress unnoticed. The rendered documentation site is one such build input: it reaches the sdist through its own force-include (`[tool.hatch.build.targets.sdist.force-include]`), so this guard also fails if that force-include is removed or broken.
### Wheel data layout
Source assets that are not Python modules are force-included with a `netbox/netbox/_data/` target path by `[tool.hatch.build.targets.wheel.force-include]`; because the wheel's `sources = ["netbox"]` setting strips one leading `netbox/`, they install under `netbox/_data/`: templates, translations, the compiled `project-static` bundles, `release.yaml`, the pre-rendered documentation site (rendered by `zensical build` into `netbox/project-static/docs/` before packaging; see [Building](#building) above), the bundled deployment examples (`contrib/`, seven files, unmodified), and the two tracked configuration templates.
The wheel bundles the rendered site itself, not the documentation sources. The documentation build is not run from the installed wheel, and there is nothing to build on the instance. In wheel mode, the default `DOCS_ROOT` and the STATICFILES `docs` prefix source both resolve to the same bundled `_data/docs` directory (see `resolve_install_paths()` in `netbox/netbox/settings_utils.py`), which `collectstatic` then picks up the same way it does for a checkout build. The sdist force-includes the same rendered site (`netbox/project-static/docs/`, kept alongside the markdown sources it was rendered from), so a wheel built from the sdist (the `verify-sdist` job, or `pip install <sdist>`) is identical in this respect.
At runtime `settings.py` detects the bundled `_data` directory and resolves the install mode, `BASE_DIR`, `NETBOX_ROOT`, and the documentation roots through `resolve_install_paths()` in `netbox/netbox/settings_utils.py`: a wheel install (`_data` present) keeps package data under `_data` and mutable instance files under `NETBOX_ROOT`; a source checkout (no `_data`) keeps the historical layout, where both roots are the project directory.
### Wheel-mode runtime
A pip-installed NetBox keeps mutable instance state out of the immutable, disposable virtual environment. `settings.py` resolves `NETBOX_ROOT` (default `/opt/netbox`, overridable via the environment) as the instance root, defaults the writable paths (`MEDIA_ROOT`, `REPORTS_ROOT`, `SCRIPTS_ROOT`) beneath it, and fixes `STATIC_ROOT` to `$NETBOX_ROOT/static`; `STATIC_ROOT` is intentionally not a `configuration.py` parameter, so the collected static path cannot drift from the instance layout the bundled deployment examples expect. In a checkout `NETBOX_ROOT` equals `BASE_DIR`, so archive and Git installs are unaffected.
Configuration loading is handled by `load_configuration()` in `netbox/netbox/settings_utils.py`. An explicit `NETBOX_CONFIGURATION` module always wins; otherwise, in wheel mode it prefers `NETBOX_ROOT/conf/configuration.py`, loading it by file path, and falls back to a legacy `NETBOX_ROOT/netbox/netbox/configuration.py` with a migration warning. The configuration directory is added to `sys.path` only while the configuration file executes, so sibling imports can resolve; `NETBOX_ROOT` itself is never added, which avoids a stale source tree shadowing the installed package. A checkout keeps importing `netbox.configuration`. For LDAP deployments, `settings.py` exposes the active configuration file's directory as the `CONFIGURATION_DIR` setting, and `load_ldap_config()` loads `ldap_config.py` from that same directory by default. This keeps the active LDAP configuration beside the active NetBox configuration, regardless of install method. One compatibility exception remains: in checkout mode only, when no sibling file exists, the historical `netbox/netbox/ldap_config.py` module is imported with a `RuntimeWarning`, so existing source installs that use a custom `NETBOX_CONFIGURATION` keep working.
### Console script
`pyproject.toml` registers a single entry point, `netbox` (`netbox.cli:main`). The wrapper resolves a few commands itself before importing Django, so they work without a configuration present:
* `netbox version` / `netbox --version` print the installed package version.
* `netbox setup` creates the local configuration files for the instance: `conf/__init__.py`, `conf/configuration.py` copied verbatim from the bundled `configuration_example.py` template, and an empty `local_requirements.txt`. It also copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi, `netbox.env`) unmodified into `<target>/contrib/`. The examples are copied as-is, and existing files are never overwritten; adapting and installing the examples (paths, systemd, the web server) remains the administrator's responsibility.
* `netbox secret-key` prints a new 50-character `SECRET_KEY` value.
These names are reserved by the wrapper. Every other command falls through to the Django management commands (`netbox upgrade`, `netbox check`, and so on), which require a valid configuration.

View File

@ -97,14 +97,23 @@ Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker)
### Update Python Dependencies
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
Before each release, update each of NetBox's Python dependencies to its most recent stable version. Loose runtime constraints (and per-package descriptions) live in `base_requirements.txt`; `requirements.txt` is the pinned, top-level dependency file consumed by the release archive, the git install flow (`upgrade.sh`), and the published wheel's dependency metadata. Optional dependency groups (for example `ldap`, `saml2`) are declared in `pyproject.toml`.
1. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
2. Run all tests and check that the UI and API function as expected.
3. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
4. Update the package versions in `requirements.txt` as appropriate.
To update the pinned requirements:
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
1. Review each constraint in `base_requirements.txt`.
2. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
3. Run all tests and check that the UI and API function as expected.
4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
5. If upgrading a dependency is breaking, constrain it in `base_requirements.txt` with an explanatory comment and revisit it for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
6. Update the pinned versions in `requirements.txt` to the versions you just tested. Keep `requirements.txt` in the existing bare `package==version` format (one top-level package per line, the same package set as `base_requirements.txt`).
7. Verify there is no drift between the policy file and the pins:
```no-highlight
python3 scripts/verify_dependencies.py
```
The published wheel's `Requires-Dist` is generated from `requirements.txt` at build time, so the package installs the same tested pins as the archive and git flows.
### Update UI Dependencies
@ -143,7 +152,7 @@ Then, compile these portable (`.po`) files for use in the application:
### Update Version and Changelog
* Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
* No manual `pyproject.toml` version edit is needed: the package version is derived automatically from `release.yaml` (`version` plus any `designation`) by the build backend.
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
!!! tip
@ -196,4 +205,25 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
* **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
Once created, the release will become available for users to install.
Once created, the release will become available for users to install from GitHub.
### Publish to Test PyPI
Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing.
After a publish run completes:
* Verify that the build, CLI smoke-test (`cli-smoke-test`), smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel.
* Verify that the publish job used the expected trusted-publishing environment (`testpypi`).
* Confirm that the new version is visible on Test PyPI.
* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module. The preview artifact is published to Test PyPI while NetBox's pinned runtime dependencies are expected to resolve from PyPI; to avoid mixed-index dependency resolution during validation, install the pinned dependencies from PyPI first, then install the Test PyPI artifact without resolving dependencies again:
```no-highlight
pip install -r requirements.txt
pip install --no-deps --index-url https://test.pypi.org/simple/ netbox==<version>
```
!!! note "Trusted publishing prerequisites"
Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment.
The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release.

View File

@ -16,6 +16,12 @@ redis-server -v
You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient.
!!! danger "Restrict access to Redis"
NetBox's background workers execute jobs read from Redis, so anyone able to write to the `tasks` database can run
arbitrary code on a worker. Treat Redis as trusted infrastructure: keep it bound to `localhost` (the default) or a
private network, and enable authentication if it is reachable by any other host. See
[Redis configuration](../configuration/required-parameters.md#redis) for details.
## Verify Service Status
Use the `redis-cli` utility to ensure the Redis service is functional:

View File

@ -95,3 +95,23 @@ If you are able to connect but receive a 502 (bad gateway) error, check the foll
* The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
* Nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`
## What's Next?
With NetBox up and running, you may want to extend its capabilities by installing one or more plugins. Plugins are optional components that add new models, views, integrations, and other functionality on top of core NetBox. Some of the most popular plugins include:
* [**NetBox Branching**](https://github.com/netboxlabs/netbox-branching) — Create isolated, changeable branches of your NetBox data, allowing multiple users to work in parallel and merge their changes.
* [**NetBox Custom Objects**](https://github.com/netboxlabs/netbox-custom-objects) — Define entirely new object types directly in the UI, without writing any code.
* [**NetBox DNS**](https://github.com/sys4/netbox-plugin-dns) — Manage DNS zones, records, and related data as an authoritative source of truth.
* [**NetBox BGP**](https://github.com/netbox-community/netbox-bgp) — Document and manage BGP sessions, communities, and routing policies.
Installing a plugin generally involves adding its Python package to `/opt/netbox/local_requirements.txt`, enabling it in the `PLUGINS` list in `configuration.py`, and running NetBox's upgrade script:
```no-highlight
$ sudo sh -c "echo '<package>' >> /opt/netbox/local_requirements.txt"
$ sudo /opt/netbox/upgrade.sh
```
Each plugin is different and may require additional configuration or setup steps, so always consult the plugin's own documentation as well as NetBox's [plugin installation guide](../plugins/installation.md) before getting started.
To browse the full catalog of available plugins, visit [netboxlabs.com/plugins](https://netboxlabs.com/plugins/).

View File

@ -168,6 +168,9 @@ Or by a set of attributes which uniquely identify the rack:
Note that if the provided parameters do not return exactly one object, a validation error is raised.
!!! note "Permissions"
When a related object is referenced by a set of attributes, the lookup is restricted to only those objects which the requesting user has permission to view. This prevents the enumeration of objects by their attributes. Referencing a related object directly by its numeric ID is always permitted, regardless of the user's view permissions for that object.
### Generic Relations
Some objects within NetBox have attributes which can reference an object of multiple types, known as _generic relations_. For example, an IP address can be assigned to either a device interface _or_ a virtual machine interface. When making this assignment via the REST API, we must specify two attributes:
@ -854,6 +857,8 @@ By default, a token can be used to perform all actions via the API that a user w
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
The client IP address is determined from the HTTP headers configured by [`HTTP_CLIENT_IP_HEADERS`](../configuration/system.md#http_client_ip_headers); see the security note there regarding header trust.
#### Creating Tokens for Other Users
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.

View File

@ -1,5 +1,39 @@
# NetBox v4.6
## v4.6.5 (2026-07-14)
### Enhancements
* [#18828](https://github.com/netbox-community/netbox/issues/18828) - Add MDC connector type for fiber ports and cables
* [#22544](https://github.com/netbox-community/netbox/issues/22544) - Provide a REST API method to update or overwrite an existing custom script module
* [#22629](https://github.com/netbox-community/netbox/issues/22629) - Enforce a lower maximum uploaded image size (50 megapixels) than the Pillow default
* [#22649](https://github.com/netbox-community/netbox/issues/22649) - Add Korean language support
### Performance Improvements
* [#22551](https://github.com/netbox-community/netbox/issues/22551) - Add a prefetch hint to the GraphQL `tags` field to avoid N+1 queries on list endpoints
* [#22589](https://github.com/netbox-community/netbox/issues/22589) - Cache serializers to avoid repeated reinstantiation on the cables list REST API endpoint
### Bug Fixes
* [#22154](https://github.com/netbox-community/netbox/issues/22154) - Correct the OpenAPI schema for relation counts on nested (brief) object representations
* [#22500](https://github.com/netbox-community/netbox/issues/22500) - Return the configured maintenance mode message for REST API requests
* [#22521](https://github.com/netbox-community/netbox/issues/22521) - Honor `RAM_BASE_UNIT` for the default memory of a virtual machine type
* [#22539](https://github.com/netbox-community/netbox/issues/22539) - Restore the available IPs button for users with constrained permissions
* [#22566](https://github.com/netbox-community/netbox/issues/22566) - Avoid name collisions when a custom script's filename matches a core app label
* [#22568](https://github.com/netbox-community/netbox/issues/22568) - Fix uncaught `ValueError` (HTTP 500) when an invalid `filter_id` query parameter is provided
* [#22573](https://github.com/netbox-community/netbox/issues/22573) - Remove persistent scrollbar on the navigation menu in Chrome
* [#22578](https://github.com/netbox-community/netbox/issues/22578) - Ensure shared objects are treated consistently across the UI and REST API
* [#22582](https://github.com/netbox-community/netbox/issues/22582) - Use a theme-aware color for interface list row separators in dark mode
* [#22598](https://github.com/netbox-community/netbox/issues/22598) - Fix `ValueError` exception when viewing background tasks under RQ 2.10
* [#22617](https://github.com/netbox-community/netbox/issues/22617) - Require the "change" permission (rather than "add") when editing objects via the bulk import form
* [#22626](https://github.com/netbox-community/netbox/issues/22626) - Ensure custom link names are escaped when rendering fails
* [#22632](https://github.com/netbox-community/netbox/issues/22632) - Fix `ValueError` raised by object-level permission checks for cross-app proxy models
* [#22652](https://github.com/netbox-community/netbox/issues/22652) - Explicitly disable autoescaping for config templates rendered via `SandboxedEnvironment`
* [#22657](https://github.com/netbox-community/netbox/issues/22657) - Escape the exception message in the `render_widget` template tag before marking it safe
---
## v4.6.4 (2026-06-30)
### Enhancements
@ -67,7 +101,7 @@
* [#22346](https://github.com/netbox-community/netbox/issues/22346) - Render SSO/SAML authentication failures as a login page message instead of an HTTP 500 error
* [#22357](https://github.com/netbox-community/netbox/issues/22357) - Remove the unused `local_context_data` field from `dcim.Module` (which no longer inherits from `ConfigContextModel`)
* [#22376](https://github.com/netbox-community/netbox/issues/22376) - Fix `AssertionError` in event rule script jobs when a device type has an image attached
* [#22388](https://github.com/netbox-community/netbox/issues/22388) - Pin redis-py to <8.0 to avoid a startup failure on older Redis releases
* [#22388](https://github.com/netbox-community/netbox/issues/22388) - Pin redis-py to 7.x to avoid a startup failure on older Redis releases
* [#22397](https://github.com/netbox-community/netbox/issues/22397) - Fix `AttributeError` exception when an unauthenticated user attempts to export devices
* [#22399](https://github.com/netbox-community/netbox/issues/22399) - Enforce object permissions on the related object when serving static media
* [#22427](https://github.com/netbox-community/netbox/issues/22427) - Validate `JSONFilter.path` to prevent ORM operator injection over JSONField contents in the GraphQL API

View File

@ -333,6 +333,7 @@ nav:
- Internationalization: 'development/internationalization.md'
- Translations: 'development/translations.md'
- Release Checklist: 'development/release-checklist.md'
- Building the Package: 'development/building-the-package.md'
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
('circuits', '0057_default_ordering_indexes'),
# Source tables (dcim_site, dcim_location) must already exist.
('dcim', '0238_ltree_paths'),
('dcim', '0240_ltree_paths'),
]
operations = [

View File

@ -4,6 +4,7 @@ from django.core.cache import cache
from django.db import models
from django.db.migrations.operations import AlterModelOptions
from django.utils.translation import gettext as _
from PIL import Image
from core.events import *
from netbox.events import EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING, EventType
@ -16,6 +17,12 @@ AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
# Use our custom destructor to ignore certain attributes when calculating field migrations
models.Field.deconstruct = custom_deconstruct
# Cap the maximum size of an image Pillow will decode, to mitigate decompression-bomb DoS attacks. Pillow raises a
# DecompressionBombError when an image's declared dimensions exceed 2x this value, before allocating pixel buffers.
# Django's & DRF's ImageField already convert that exception into a validation error, so no additional handling is
# required; this single assignment covers all image upload paths (UI forms, REST API, and direct model saves).
Image.MAX_IMAGE_PIXELS = 25_000_000
class CoreConfig(AppConfig):
name = "core"

View File

@ -0,0 +1,107 @@
"""Run the NetBox application tasks required after installing or upgrading NetBox.
The command runs the NetBox application-level tasks that prepare the database and
static assets after the package and configuration are already in place - for both a
fresh installation and an upgrade. It does not perform host or bootstrap work
(creating the virtual environment, installing packages, configuring services); that
stays in upgrade.sh and the documented pip steps.
"""
import os
import subprocess
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
def _docs_source_root():
# mkdocs.yml sits beside the application root in a checkout. Wheels ship the
# pre-rendered site instead of the sources: no mkdocs.yml, the build is skipped.
candidate = os.path.dirname(settings.BASE_DIR)
if os.path.isfile(os.path.join(candidate, 'mkdocs.yml')):
return candidate
return None
class Command(BaseCommand):
help = "Run the NetBox application tasks required after installing or upgrading NetBox."
def add_arguments(self, parser):
parser.add_argument('--no-input', action='store_true', dest='no_input',
help="Do not prompt for user input.")
parser.add_argument('--readonly', action='store_true', dest='readonly',
help="Skip all tasks that modify the database or filesystem "
"(no migrations, no static collection).")
parser.add_argument('--skip-migrations', action='store_true', dest='skip_migrations',
help="Skip applying database migrations.")
parser.add_argument('--skip-static', action='store_true', dest='skip_static',
help="Skip collecting static files.")
parser.add_argument('--skip-reindex', action='store_true', dest='skip_reindex',
help="Skip rebuilding the search index.")
parser.add_argument('--build-docs', action='store_true', dest='build_docs',
help="Build the local documentation (requires the documentation source tree).")
def handle(self, *args, **options):
out, style = self.stdout, self.style
out.write(style.SUCCESS("Running NetBox upgrade tasks..."))
# Database migrations (writes to the database)
if options['skip_migrations'] or options['readonly']:
out.write("Skipping database migrations.")
else:
out.write("Applying database migrations...")
call_command('migrate', interactive=not options['no_input'], stdout=out)
# Missing cable paths (writes to the database)
if options['readonly']:
out.write("Skipping cable path check.")
else:
out.write("Checking for missing cable paths...")
call_command('trace_paths', no_input=options['no_input'], stdout=out)
# Documentation (filesystem; needs the documentation source tree)
if options['readonly'] and options['build_docs']:
out.write("Skipping documentation build.")
elif options['build_docs']:
docs_root = _docs_source_root()
if docs_root is None:
out.write(style.WARNING(
"Skipping documentation build; the documentation source tree is not available "
"in this installation."
))
else:
out.write("Building documentation...")
# -c cleans the cache; -s (strict) is deliberately omitted so a docs
# warning cannot abort an instance upgrade.
subprocess.run(['zensical', 'build', '-c'], cwd=docs_root, check=True)
# Static files (filesystem)
if options['skip_static'] or options['readonly']:
out.write("Skipping static file collection.")
else:
out.write("Collecting static files...")
call_command('collectstatic', interactive=not options['no_input'], stdout=out)
# Stale content types (writes to the database)
if options['readonly']:
out.write("Skipping stale content type removal.")
else:
out.write("Removing stale content types...")
call_command('remove_stale_contenttypes', interactive=not options['no_input'], stdout=out)
# Search index (writes to the database)
if options['skip_reindex'] or options['readonly']:
out.write("Skipping search index rebuild.")
else:
out.write("Rebuilding the search index (lazily)...")
call_command('reindex', lazy=True, stdout=out)
# Expired sessions (writes to the database)
if options['readonly']:
out.write("Skipping expired session cleanup.")
else:
out.write("Clearing expired sessions...")
call_command('clearsessions', stdout=out)
out.write(style.SUCCESS("Finished NetBox upgrade tasks."))

View File

@ -449,7 +449,7 @@ class ChangeLogAPITestCase(APITestCase):
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
self.add_permissions('dcim.add_site', 'extras.view_tag')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -481,7 +481,7 @@ class ChangeLogAPITestCase(APITestCase):
]
}
self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.change_site')
self.add_permissions('dcim.change_site', 'extras.view_tag')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.put(url, data, format='json', **self.header)

View File

@ -1,3 +1,5 @@
import os
import tempfile
from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -7,7 +9,7 @@ from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from core.choices import DataSourceStatusChoices
from core.management.commands import nbshell
from core.management.commands import nbshell, upgrade
from core.management.commands.rqworker import DEFAULT_QUEUES
@ -315,3 +317,76 @@ class SyncDataSourceTestCase(TestCase):
self.assertIn('[1] Syncing source-a', out.getvalue())
self.assertIn('[2] Syncing source-b', out.getvalue())
self.assertIn('Finished.', out.getvalue())
class UpgradeCommandTest(TestCase):
"""The upgrade command orchestrates the application task sequence for installs and upgrades."""
def _run(self, **kwargs):
out = StringIO()
with (
patch('core.management.commands.upgrade.call_command') as cc,
patch('core.management.commands.upgrade.subprocess.run') as sub,
):
call_command('upgrade', stdout=out, **kwargs)
return [c.args[0] for c in cc.call_args_list], cc, sub, out.getvalue()
def test_full_sequence_order(self):
seq, _, sub, _ = self._run()
self.assertEqual(seq, [
'migrate', 'trace_paths',
'collectstatic', 'remove_stale_contenttypes', 'reindex', 'clearsessions',
])
sub.assert_not_called() # docs not built by default
def test_readonly_skips_all_tasks_including_static(self):
"""--readonly prints a skip message for every task, including the three without dedicated flags."""
seq, _, sub, out = self._run(readonly=True)
self.assertEqual(seq, [])
sub.assert_not_called()
self.assertIn('Skipping database migrations.', out)
self.assertIn('Skipping cable path check.', out)
self.assertIn('Skipping static file collection.', out)
self.assertIn('Skipping stale content type removal.', out)
self.assertIn('Skipping search index rebuild.', out)
self.assertIn('Skipping expired session cleanup.', out)
def test_skip_flags(self):
seq, _, _, _ = self._run(skip_migrations=True, skip_static=True, skip_reindex=True)
self.assertEqual(
seq,
['trace_paths', 'remove_stale_contenttypes', 'clearsessions'],
)
def test_build_docs_invokes_zensical_when_sources_present(self):
with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
_, _, sub, _ = self._run(build_docs=True)
sub.assert_called_once()
self.assertEqual(sub.call_args.args[0], ['zensical', 'build', '-c'])
def test_build_docs_skipped_when_sources_absent(self):
with patch('core.management.commands.upgrade._docs_source_root', return_value=None):
_, _, sub, _ = self._run(build_docs=True)
sub.assert_not_called()
def test_readonly_with_build_docs_skips_docs(self):
with patch('core.management.commands.upgrade._docs_source_root', return_value='/repo'):
_, _, sub, _ = self._run(readonly=True, build_docs=True)
sub.assert_not_called()
def test_docs_source_root_checkout_shaped(self):
"""mkdocs.yml beside the application root (checkout layout) is found."""
with tempfile.TemporaryDirectory() as root:
base_dir = os.path.join(root, 'netbox')
os.mkdir(base_dir)
open(os.path.join(root, 'mkdocs.yml'), 'w').close()
with override_settings(BASE_DIR=base_dir):
self.assertEqual(upgrade._docs_source_root(), root)
def test_docs_source_root_none_when_absent(self):
"""No mkdocs.yml beside the application root returns None."""
with tempfile.TemporaryDirectory() as root:
base_dir = os.path.join(root, 'netbox')
os.mkdir(base_dir)
with override_settings(BASE_DIR=base_dir):
self.assertIsNone(upgrade._docs_source_root())

View File

@ -3,6 +3,7 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
from dcim.utils import reconcile_port_mappings
from utilities.api import get_serializer_for_model
__all__ = (
@ -68,17 +69,25 @@ class PortSerializer(serializers.ModelSerializer):
return PortTemplateMapping, 'rear_port'
raise ValueError(f"Could not determine mapping details for {self.__class__}")
def _reconcile_mappings(self, instance, mappings):
mapping_model, fk_name = self._mapper
other_field = 'rear_port' if fk_name == 'front_port' else 'front_port'
# Normalize the opposite-port FK from a model instance to its id so the mappings can be
# reconciled by value.
desired = []
for attrs in mappings:
attrs = dict(attrs)
if other_field in attrs:
attrs[f'{other_field}_id'] = attrs.pop(other_field).pk
desired.append(attrs)
reconcile_port_mappings(mapping_model, parent_field=fk_name, parent=instance, desired=desired)
def create(self, validated_data):
mappings = validated_data.pop('mappings', [])
instance = super().create(validated_data)
# Create port mappings
mapping_model, fk_name = self._mapper
for attrs in mappings:
mapping_model.objects.create(**{
fk_name: instance,
**attrs,
})
self._reconcile_mappings(instance, mappings)
return instance
@ -86,14 +95,9 @@ class PortSerializer(serializers.ModelSerializer):
mappings = validated_data.pop('mappings', None)
instance = super().update(instance, validated_data)
# Only reconcile when the client supplied rear_ports; a PATCH that omits it leaves the
# existing mappings untouched.
if mappings is not None:
# Update port mappings
mapping_model, fk_name = self._mapper
mapping_model.objects.filter(**{fk_name: instance}).delete()
for attrs in mappings:
mapping_model.objects.create(**{
fk_name: instance,
**attrs,
})
self._reconcile_mappings(instance, mappings)
return instance

View File

@ -1676,6 +1676,7 @@ class PortTypeChoices(ChoiceSet):
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
TYPE_MDC = 'mdc'
TYPE_SMA_905 = 'sma-905'
TYPE_SMA_906 = 'sma-906'
TYPE_URM_P2 = 'urm-p2'
@ -1747,6 +1748,7 @@ class PortTypeChoices(ChoiceSet):
Choice(TYPE_ST, 'ST'),
Choice(TYPE_CS, 'CS'),
Choice(TYPE_SN, 'SN'),
Choice(TYPE_MDC, 'MDC'),
Choice(TYPE_SMA_905, 'SMA 905'),
Choice(TYPE_SMA_906, 'SMA 906'),
Choice(TYPE_URM_P2, 'URM-P2'),

View File

@ -1,12 +1,10 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import connection
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _
from dcim.constants import LOCATION_SCOPE_TYPES
from dcim.models import PortMapping, PortTemplateMapping
from dcim.utils import reconcile_port_mappings
from utilities.forms import GenericObjectFormMixin
from utilities.forms.fields import (
CSVContentTypeField,
@ -131,43 +129,24 @@ class FrontPortFormMixin(forms.Form):
def _save_m2m(self):
super()._save_m2m()
# TODO: Can this be made more efficient?
# Delete existing rear port mappings
self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete()
# Create new rear port mappings
mappings = []
if self.port_mapping_model is PortTemplateMapping:
params = {
'device_type_id': self.instance.device_type_id,
'module_type_id': self.instance.module_type_id,
}
else:
params = {
'device_id': self.instance.device_id,
}
# Build the desired set of mappings from the submitted rear port pairs, assigning front port
# positions in order. reconcile_port_mappings() then writes only the difference, so re-saving
# a front port without changing its wiring produces no writes (and no changelog churn).
desired = []
for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
rear_port_id, rear_port_position = rp_position.split(':')
mappings.append(
self.port_mapping_model(**{
**params,
'front_port_id': self.instance.pk,
'front_port_position': i,
'rear_port_id': rear_port_id,
'rear_port_position': rear_port_position,
})
)
self.port_mapping_model.objects.bulk_create(mappings)
# Send post_save signals
for mapping in mappings:
post_save.send(
sender=PortMapping,
instance=mapping,
created=True,
raw=False,
using=connection,
update_fields=None
)
desired.append({
'front_port_position': i,
'rear_port_id': int(rear_port_id),
'rear_port_position': int(rear_port_position),
})
reconcile_port_mappings(
self.port_mapping_model,
parent_field='front_port',
parent=self.instance,
desired=desired,
)
def _get_rear_port_choices(self, parent_filter, front_port):
"""

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0237_module_remove_local_context_data'),
]
operations = [
migrations.AlterField(
model_name='cable',
name='_abs_length',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=14, null=True),
),
]

View File

@ -0,0 +1,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dcim", "0238_alter_cable__abs_length"),
]
operations = [
migrations.AddField(
model_name="portmapping",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="portmapping",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
migrations.AddField(
model_name="porttemplatemapping",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name="porttemplatemapping",
name="last_updated",
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@ -77,7 +77,7 @@ LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0237_module_remove_local_context_data'),
('dcim', '0239_add_portmapping_objectchange'),
]
operations = [

View File

@ -30,7 +30,7 @@ COMPONENT_TABLES = (
class Migration(migrations.Migration):
dependencies = [
('dcim', '0238_ltree_paths'),
('dcim', '0240_ltree_paths'),
]
operations = [

View File

@ -3,7 +3,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0239_denormalization_triggers'),
('dcim', '0241_denormalization_triggers'),
]
operations = [

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0240_device__config_context_data'),
('dcim', '0242_device__config_context_data'),
('extras', '0139_alter_customfieldchoiceset_extra_choices'),
('tenancy', '0025_ltree_paths'),
('users', '0016_default_ordering_indexes'),

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("dcim", "0241_consolidate_unique_constraints"),
("dcim", "0243_consolidate_unique_constraints"),
]
operations = [

View File

@ -10,7 +10,7 @@ import utilities.json
class Migration(migrations.Migration):
dependencies = [
('dcim', '0242_add_devicetype_end_of_life'),
('dcim', '0244_add_devicetype_end_of_life'),
('extras', '0141_custom_field_nulls_first'),
('users', '0016_default_ordering_indexes'),
]

View File

@ -29,6 +29,8 @@ class PortMappingBase(models.Model):
),
)
# Change-logged but private: no public API/URL, and the delete-time UPDATE cascade onto the
# parent ports stays suppressed (see #21390, #22270).
_netbox_private = True
class Meta:
@ -44,6 +46,9 @@ class PortMappingBase(models.Model):
),
)
def __str__(self):
return f'{self.front_port}:{self.front_port_position} to {self.rear_port}:{self.rear_port_position}'
def clean(self):
super().clean()

View File

@ -133,7 +133,7 @@ class Cable(PrimaryModel):
)
# Stores the normalized length (in meters) for database ordering
_abs_length = models.DecimalField(
max_digits=10,
max_digits=14,
decimal_places=4,
blank=True,
null=True
@ -1169,10 +1169,14 @@ class CablePath(models.Model):
def get_total_length(self):
"""
Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive.
Return a tuple containing the sum of the length of each cable and the distance of each circuit
crossed by the path, and a flag indicating whether the length is definitive.
"""
cable_ct = ObjectType.objects.get_for_model(Cable).pk
from circuits.models import CircuitTermination
object_types = ObjectType.objects.get_for_models(Cable, CircuitTermination)
cable_ct = object_types[Cable].pk
circuit_termination_ct = object_types[CircuitTermination].pk
# Pre-cache cable lengths by ID
cable_ids = self.get_cable_ids()
@ -1181,20 +1185,51 @@ class CablePath(models.Model):
for cable in Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False).values('pk', '_abs_length')
}
# Pre-cache the circuit terminations within the path, along with their circuits
circuit_termination_ids = []
for node in self._nodes:
ct, pk = decompile_path_node(node)
if ct == circuit_termination_ct:
circuit_termination_ids.append(pk)
circuit_terminations = CircuitTermination.objects.select_related('circuit').in_bulk(circuit_termination_ids)
# Iterate through each set of nodes in the path. For cables, add the length of the longest cable to the total
# length of the path.
# length of the path. Also map each set of nodes to its circuit terminations, keyed by circuit ID.
total_length = 0
circuit_hops = []
for node_set in self.path:
hop_length = 0
hop_terminations = {}
for node in node_set:
ct, pk = decompile_path_node(node)
if ct != cable_ct:
break # Not a cable
if pk in cables and cables[pk] > hop_length:
hop_length = cables[pk]
if ct == cable_ct:
if pk in cables and cables[pk] > hop_length:
hop_length = cables[pk]
elif ct == circuit_termination_ct:
termination = circuit_terminations.get(pk)
if termination is not None:
hop_terminations[termination.circuit_id] = termination
else:
break # Neither a cable nor a circuit termination
total_length += hop_length
circuit_hops.append(hop_terminations)
is_definitive = len(cables) == len(cable_ids)
# Unresolvable circuit terminations may conceal a crossing, so they render the total non-definitive
is_definitive = len(cables) == len(cable_ids) and len(circuit_terminations) == len(set(circuit_termination_ids))
# A circuit crossing appears as two adjacent sets of opposing terminations of the same circuit. For each
# crossing, add the longest distance among the circuits crossed, mirroring the handling of parallel cables.
for near_hop, far_hop in itertools.pairwise(circuit_hops):
crossing_distance = 0
for circuit_id in near_hop.keys() & far_hop.keys():
if near_hop[circuit_id].term_side == far_hop[circuit_id].term_side:
continue
distance = near_hop[circuit_id].circuit._abs_distance
if distance is None:
is_definitive = False
elif distance > crossing_distance:
crossing_distance = distance
total_length += crossing_distance
return total_length, is_definitive

View File

@ -11,6 +11,7 @@ from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from dcim.utils import get_module_bay_positions, resolve_module_placeholder
from netbox.models import ChangeLoggedModel
from netbox.models.features import ChangeLoggingMixin
from netbox.models.ltree import LtreeManager, LtreeModel
from utilities.exceptions import AbortRequest
from utilities.fields import ColorField, NaturalOrderingField
@ -543,7 +544,7 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
}
class PortTemplateMapping(PortMappingBase):
class PortTemplateMapping(ChangeLoggingMixin, PortMappingBase):
"""
Maps a FrontPortTemplate & position to a RearPortTemplate & position.
"""
@ -572,6 +573,10 @@ class PortTemplateMapping(PortMappingBase):
related_name='mappings',
)
class Meta(PortMappingBase.Meta):
# Inherit the unique constraints from PortMappingBase.Meta.
pass
def clean(self):
super().clean()

View File

@ -15,6 +15,7 @@ from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin
from netbox.choices import ColorChoices
from netbox.models import NetBoxModel, OrganizationalModel
from netbox.models.features import ChangeLoggingMixin
from netbox.models.ltree import LtreeManager, LtreeModel, SortPathField
from netbox.models.mixins import OwnerMixin
from utilities.fields import ColorField, NaturalOrderingField
@ -1196,7 +1197,7 @@ class Interface(
# Pass-through ports
#
class PortMapping(PortMappingBase):
class PortMapping(ChangeLoggingMixin, PortMappingBase):
"""
Maps a FrontPort & position to a RearPort & position.
"""
@ -1216,6 +1217,10 @@ class PortMapping(PortMappingBase):
related_name='mappings',
)
class Meta(PortMappingBase.Meta):
# Inherit the unique constraints from PortMappingBase.Meta.
pass
def clean(self):
super().clean()

View File

@ -72,15 +72,18 @@ class CachedScopeMixin(models.Model):
blank=True,
null=True
)
# SET_NULL, not CASCADE: these cache an ancestor of the actual scope, so deleting that
# ancestor must not delete this object. Deletion of a Region/SiteGroup that *is* the
# actual scope is handled independently via its GenericRelation to this model.
_region = models.ForeignKey(
to='dcim.Region',
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
blank=True,
null=True
)
_site_group = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
blank=True,
null=True
)

View File

@ -43,6 +43,18 @@ class Region(ContactsMixin, NestedLtreeGroupModel):
object_id_field='scope_id',
related_query_name='region'
)
clusters = GenericRelation(
to='virtualization.Cluster',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='region'
)
wireless_lans = GenericRelation(
to='wireless.WirelessLAN',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='region'
)
class Meta:
ordering = ('sort_path',)
@ -96,6 +108,18 @@ class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
object_id_field='scope_id',
related_query_name='site_group'
)
clusters = GenericRelation(
to='virtualization.Cluster',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site_group'
)
wireless_lans = GenericRelation(
to='wireless.WirelessLAN',
content_type_field='scope_type',
object_id_field='scope_id',
related_query_name='site_group'
)
class Meta:
ordering = ('sort_path',)

View File

@ -4,6 +4,7 @@
"cablebundle:api_list_objects": 13,
"cablebundle:list_objects_with_permission": 20,
"cabletermination:api_list_objects": 16,
"consoleconnection:list_objects_with_permission": 29,
"consoleport:api_list_objects": 14,
"consoleport:list_objects_with_permission": 21,
"consoleporttemplate:api_list_objects": 11,
@ -24,6 +25,7 @@
"frontporttemplate:api_list_objects": 12,
"interface:api_list_objects": 23,
"interface:list_objects_with_permission": 21,
"interfaceconnection:list_objects_with_permission": 41,
"interfacetemplate:api_list_objects": 11,
"inventoryitem:api_list_objects": 20,
"inventoryitem:list_objects_with_permission": 23,
@ -49,6 +51,7 @@
"moduletypeprofile:list_objects_with_permission": 20,
"platform:api_list_objects": 13,
"platform:list_objects_with_permission": 21,
"powerconnection:list_objects_with_permission": 29,
"powerfeed:api_list_objects": 15,
"powerfeed:list_objects_with_permission": 22,
"poweroutlet:api_list_objects": 14,

View File

@ -1,10 +1,11 @@
from unittest import skip
from circuits.models import CircuitTermination
from dcim.choices import CableProfileChoices
from circuits.models import Circuit, CircuitTermination, ProviderNetwork
from dcim.choices import CableLengthUnitChoices, CableProfileChoices
from dcim.models import *
from dcim.svg import CableTraceSVG
from dcim.tests.utils import BaseCablePathTestCase
from netbox.choices import DistanceUnitChoices
class CablePathTestCase(BaseCablePathTestCase):
@ -1954,6 +1955,362 @@ class CablePathTestCase(BaseCablePathTestCase):
CableTraceSVG(interfaces[1]).render()
CableTraceSVG(interfaces[2]).render()
def test_226_total_length_via_circuit(self):
"""
[IF1] --C1-- [CT1] [CT2] --C2-- [IF2]
"""
self.circuit.distance = 10
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='Z'
)
# Create cables
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[interface1],
b_terminations=[circuittermination1]
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[circuittermination2],
b_terminations=[interface2]
)
cable2.clean()
cable2.save()
# Check for complete paths in both directions
paths = [
self.assertPathExists(
(interface1, cable1, circuittermination1, circuittermination2, cable2, interface2),
is_complete=True,
is_active=True
),
self.assertPathExists(
(interface2, cable2, circuittermination2, circuittermination1, cable1, interface1),
is_complete=True,
is_active=True
),
]
# The crossed circuit's distance counts toward the total path length
for path in paths:
self.assertEqual(path.get_total_length(), (10400, True))
# An unset circuit distance makes the total length non-definitive
self.circuit.distance = None
self.circuit.save()
for path in paths:
self.assertEqual(path.get_total_length(), (400, False))
# A circuit distance of zero is a known value and remains definitive
self.circuit.distance = 0
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
for path in paths:
self.assertEqual(path.get_total_length(), (400, True))
def test_227_total_length_via_circuit_without_peer_termination(self):
"""
[IF1] --C1-- [CT1]
"""
self.circuit.distance = 10
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[interface1],
b_terminations=[circuittermination1]
)
cable1.clean()
cable1.save()
# The distance of a circuit which the path does not cross is excluded
path = self.assertPathExists(
(interface1, cable1, circuittermination1),
is_complete=False
)
self.assertEqual(path.get_total_length(), (200, True))
def test_228_total_length_via_multiple_circuits(self):
"""
[IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2]
"""
self.circuit.distance = 10
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
circuit2 = Circuit.objects.create(
provider=self.circuit.provider,
type=self.circuit.type,
cid='Circuit 2',
distance=5,
distance_unit=DistanceUnitChoices.UNIT_KILOMETER
)
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='Z'
)
circuittermination3 = CircuitTermination.objects.create(
circuit=circuit2,
termination=self.site,
term_side='A'
)
circuittermination4 = CircuitTermination.objects.create(
circuit=circuit2,
termination=self.site,
term_side='Z'
)
# Create cables
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[interface1],
b_terminations=[circuittermination1]
)
cable1.clean()
cable1.save()
cable2 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=100,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[circuittermination2],
b_terminations=[circuittermination3]
)
cable2.clean()
cable2.save()
cable3 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[circuittermination4],
b_terminations=[interface2]
)
cable3.clean()
cable3.save()
# Check for complete paths in both directions
paths = [
self.assertPathExists(
(
interface1, cable1, circuittermination1, circuittermination2, cable2, circuittermination3,
circuittermination4, cable3, interface2,
),
is_complete=True,
is_active=True
),
self.assertPathExists(
(
interface2, cable3, circuittermination4, circuittermination3, cable2, circuittermination2,
circuittermination1, cable1, interface1,
),
is_complete=True,
is_active=True
),
]
# Each crossed circuit adds its distance to the total path length
for path in paths:
self.assertEqual(path.get_total_length(), (15500, True))
def test_229_total_length_via_circuit_to_site(self):
"""
[IF1] --C1-- [CT1] [CT2] --> [Site2]
"""
self.circuit.distance = 10
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
site2 = Site.objects.create(name='Site 2', slug='site-2')
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=site2,
term_side='Z'
)
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[interface1],
b_terminations=[circuittermination1]
)
cable1.clean()
cable1.save()
# The distance of a circuit crossed to reach its far-side site counts toward the total length
path = self.assertPathExists(
(interface1, cable1, circuittermination1, circuittermination2, site2),
is_active=True
)
self.assertEqual(path.get_total_length(), (10200, True))
def test_230_total_length_via_circuit_to_providernetwork(self):
"""
[IF1] --C1-- [CT1] [CT2] --> [PN1]
"""
self.circuit.distance = 10
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit,
termination=providernetwork,
term_side='Z'
)
cable1 = Cable(
profile=CableProfileChoices.SINGLE_1C1P,
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[interface1],
b_terminations=[circuittermination1]
)
cable1.clean()
cable1.save()
# The distance of a circuit crossed to reach a provider network counts toward the total length
path = self.assertPathExists(
(interface1, cable1, circuittermination1, circuittermination2, providernetwork),
is_complete=True,
is_active=True
)
self.assertEqual(path.get_total_length(), (10200, True))
def test_231_total_length_via_parallel_circuits(self):
"""
[IF1] --C1-- [CT1_A] [CT1_Z] --C2-- [IF2]
[CT2_A] [CT2_Z]
"""
self.circuit.distance = 10
self.circuit.distance_unit = DistanceUnitChoices.UNIT_KILOMETER
self.circuit.save()
circuit2 = Circuit.objects.create(
provider=self.circuit.provider,
type=self.circuit.type,
cid='Circuit 2',
distance=5,
distance_unit=DistanceUnitChoices.UNIT_KILOMETER
)
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1_A = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='A'
)
circuittermination1_Z = CircuitTermination.objects.create(
circuit=self.circuit,
termination=self.site,
term_side='Z'
)
circuittermination2_A = CircuitTermination.objects.create(
circuit=circuit2,
termination=self.site,
term_side='A'
)
circuittermination2_Z = CircuitTermination.objects.create(
circuit=circuit2,
termination=self.site,
term_side='Z'
)
# Create cables
cable1 = Cable(
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[interface1],
b_terminations=[circuittermination1_A, circuittermination2_A]
)
cable1.clean()
cable1.save()
cable2 = Cable(
length=200,
length_unit=CableLengthUnitChoices.UNIT_METER,
a_terminations=[circuittermination1_Z, circuittermination2_Z],
b_terminations=[interface2]
)
cable2.clean()
cable2.save()
# Check for complete paths in both directions
paths = [
self.assertPathExists(
(
interface1, cable1, (circuittermination1_A, circuittermination2_A),
(circuittermination1_Z, circuittermination2_Z), cable2, interface2,
),
is_complete=True,
is_active=True
),
self.assertPathExists(
(
interface2, cable2, (circuittermination1_Z, circuittermination2_Z),
(circuittermination1_A, circuittermination2_A), cable1, interface1,
),
is_complete=True,
is_active=True
),
]
# Parallel circuits crossed in the same hop count only the longest distance
for path in paths:
self.assertEqual(path.get_total_length(), (10400, True))
# An unset distance on one parallel circuit keeps the longest known distance but is non-definitive
circuit2.distance = None
circuit2.save()
for path in paths:
self.assertEqual(path.get_total_length(), (10400, False))
def test_304_add_port_mapping_between_connected_ports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2]

View File

@ -1,3 +1,5 @@
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db.models.signals import post_save
from django.test import TestCase, tag
@ -2415,6 +2417,32 @@ class CableTestCase(TestCase):
interface = Interface(device=device, name='tmp', cable=cable)
self.assertIsNone(interface.path)
def test_cable_length_normalization_large_kilometer_value(self):
"""
A large kilometer length must pass validation and fit in the normalized length field.
"""
cable = Cable.objects.first()
cable.length = Decimal('1234')
cable.length_unit = CableLengthUnitChoices.UNIT_KILOMETER
cable.full_clean()
cable.save()
cable.refresh_from_db()
self.assertEqual(cable._abs_length, Decimal('1234000.0000'))
def test_cable_length_normalization_maximum_mile_value(self):
"""
The maximum length value expressed in miles must fit in the normalized length field.
"""
cable = Cable.objects.first()
cable.length = Decimal('999999.99')
cable.length_unit = CableLengthUnitChoices.UNIT_MILE
cable.full_clean()
cable.save()
cable.refresh_from_db()
self.assertEqual(cable._abs_length, Decimal('1609343983.9066'))
class CableTerminationTestCase(TestCase):
@ -2730,6 +2758,70 @@ class VCPositionTokenTestCase(TestCase):
interface = device.interfaces.get(name='ge-2/1/0')
self.assertEqual(interface.label, 'Member 2 / Slot 1')
@tag('regression') # Ref: #22707
def test_vc_position_token_interface_bridge_device_type_template(self):
site = Site.objects.first()
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
bridge_template = InterfaceTemplate.objects.create(
device_type=device_type,
name='br-{vc_position}',
type='bridge',
)
InterfaceTemplate.objects.create(
device_type=device_type,
name='ge-{vc_position}/0/1',
type='1000base-t',
bridge=bridge_template,
)
vc = VirtualChassis.objects.create(name='Test VC 5')
device = Device.objects.create(
name='Device VC 5', device_type=device_type, role=device_role,
site=site, virtual_chassis=vc, vc_position=5,
)
interface = device.interfaces.get(name='ge-5/0/1')
self.assertEqual(interface.bridge, device.interfaces.get(name='br-5'))
@tag('regression') # Ref: #22707
def test_vc_position_token_port_mapping_device_type_template(self):
site = Site.objects.first()
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
rear_port_template = RearPortTemplate.objects.create(
device_type=device_type,
name='rp-{vc_position}/1',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
front_port_template = FrontPortTemplate.objects.create(
device_type=device_type,
name='fp-{vc_position}/1',
type=PortTypeChoices.TYPE_LC,
positions=1,
)
PortTemplateMapping.objects.create(
device_type=device_type,
front_port=front_port_template,
front_port_position=1,
rear_port=rear_port_template,
rear_port_position=1,
)
vc = VirtualChassis.objects.create(name='Test VC 6')
device = Device.objects.create(
name='Device VC 6', device_type=device_type, role=device_role,
site=site, virtual_chassis=vc, vc_position=6,
)
front_port = FrontPort.objects.get(device=device, name='fp-6/1')
rear_port = RearPort.objects.get(device=device, name='rp-6/1')
mapping = PortMapping.objects.get(device=device, front_port=front_port)
self.assertEqual(mapping.rear_port, rear_port)
self.assertEqual(mapping.front_port_position, 1)
self.assertEqual(mapping.rear_port_position, 1)
class SiteSignalTestCase(TestCase):

View File

@ -0,0 +1,361 @@
import uuid
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase, tag
from django.urls import reverse
from rest_framework import status
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange
from dcim.choices import PortTypeChoices
from dcim.models import (
Device,
DeviceRole,
DeviceType,
FrontPort,
FrontPortTemplate,
Manufacturer,
PortMapping,
PortTemplateMapping,
RearPort,
RearPortTemplate,
Site,
)
from dcim.utils import reconcile_port_mappings
from netbox.context_managers import event_tracking
from users.models import User
from utilities.testing import APITestCase
def _build_request(user):
request = RequestFactory().get('/')
request.id = uuid.uuid4()
request.user = user
return request
class ReconcilePortMappingsTestCase(TestCase):
"""
Exercise dcim.utils.reconcile_port_mappings and confirm that PortMapping now participates in
change logging (#22644): only the difference is written, so unchanged mappings keep their PK and
emit no ObjectChange.
"""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='testuser', password='pw')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
site = Site.objects.create(name='Site 1', slug='site-1')
cls.device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
cls.front_port = FrontPort.objects.create(
device=cls.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=4
)
cls.rear_ports = [
RearPort.objects.create(
device=cls.device, name=f'Rear Port {i}', type=PortTypeChoices.TYPE_8P8C, positions=4
)
for i in range(1, 4)
]
def _desired(self, *pairs):
# Each pair is (front_port_position, rear_port, rear_port_position).
return [
{'front_port_position': fpp, 'rear_port_id': rp.pk, 'rear_port_position': rpp}
for fpp, rp, rpp in pairs
]
def _reconcile(self, desired):
request = _build_request(self.user)
with event_tracking(request):
reconcile_port_mappings(PortMapping, parent_field='front_port', parent=self.front_port, desired=desired)
def _mapping_changes(self, action=None):
changes = ObjectChange.objects.filter(changed_object_type=ContentType.objects.get_for_model(PortMapping))
if action is not None:
changes = changes.filter(action=action)
return changes
def _current_pks(self):
return set(PortMapping.objects.filter(front_port=self.front_port).values_list('pk', flat=True))
def test_create_records_objectchange(self):
self._reconcile(self._desired(
(1, self.rear_ports[0], 1),
(2, self.rear_ports[1], 1),
))
self.assertEqual(PortMapping.objects.filter(front_port=self.front_port).count(), 2)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 2)
@tag('regression') # Ref: #22644
def test_noop_resave_writes_nothing(self):
# Re-saving a front port without changing its wiring must not mint new PKs or emit changelog
# entries — this is what previously broke branch merges (colliding on the unique constraint
# when both sides replayed DELETE(old-pk) + CREATE(new-pk)).
desired = self._desired(
(1, self.rear_ports[0], 1),
(2, self.rear_ports[1], 1),
)
self._reconcile(desired)
original_pks = self._current_pks()
ObjectChange.objects.all().delete()
self._reconcile(desired)
self.assertEqual(self._mapping_changes().count(), 0)
self.assertEqual(self._current_pks(), original_pks)
def test_repointing_a_slot_deletes_and_creates(self):
self._reconcile(self._desired((1, self.rear_ports[0], 1)))
original_pk = self._current_pks().pop()
ObjectChange.objects.all().delete()
# Same front port position, different rear port: the slot is re-pointed.
self._reconcile(self._desired((1, self.rear_ports[1], 1)))
mappings = PortMapping.objects.filter(front_port=self.front_port)
self.assertEqual(mappings.count(), 1)
mapping = mappings.first()
self.assertEqual(mapping.rear_port_id, self.rear_ports[1].pk)
self.assertNotEqual(mapping.pk, original_pk)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).count(), 1)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 1)
def test_unchanged_rows_survive_alongside_changed_rows(self):
self._reconcile(self._desired(
(1, self.rear_ports[0], 1),
(2, self.rear_ports[1], 1),
))
unchanged_pk = PortMapping.objects.get(front_port=self.front_port, front_port_position=1).pk
ObjectChange.objects.all().delete()
# Position 1 is untouched; position 2 is re-pointed to a third rear port.
self._reconcile(self._desired(
(1, self.rear_ports[0], 1),
(2, self.rear_ports[2], 1),
))
self.assertEqual(PortMapping.objects.get(front_port=self.front_port, front_port_position=1).pk, unchanged_pk)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 1)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).count(), 1)
def test_removing_a_mapping_records_delete(self):
self._reconcile(self._desired(
(1, self.rear_ports[0], 1),
(2, self.rear_ports[1], 1),
))
ObjectChange.objects.all().delete()
self._reconcile(self._desired((1, self.rear_ports[0], 1)))
self.assertEqual(PortMapping.objects.filter(front_port=self.front_port).count(), 1)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).count(), 1)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 0)
def test_swapping_positions_preserves_constraints(self):
# Two front port positions pointing at the same rear port's positions 1 and 2.
self._reconcile(self._desired(
(1, self.rear_ports[0], 1),
(2, self.rear_ports[0], 2),
))
# Swap their rear port positions. Reconcile deletes both changed rows before recreating, so
# the transient state never violates the (rear_port, rear_port_position) unique constraint.
self._reconcile(self._desired(
(1, self.rear_ports[0], 2),
(2, self.rear_ports[0], 1),
))
self.assertEqual(
PortMapping.objects.get(front_port=self.front_port, front_port_position=1).rear_port_position, 2
)
self.assertEqual(
PortMapping.objects.get(front_port=self.front_port, front_port_position=2).rear_port_position, 1
)
def test_direct_delete_records_objectchange(self):
self._reconcile(self._desired((1, self.rear_ports[0], 1)))
mapping = PortMapping.objects.get(front_port=self.front_port)
mapping_pk = mapping.pk
ObjectChange.objects.all().delete()
request = _build_request(self.user)
with event_tracking(request):
mapping.delete()
self.assertTrue(
self._mapping_changes(ObjectChangeActionChoices.ACTION_DELETE).filter(changed_object_id=mapping_pk).exists()
)
class ReconcilePortTemplateMappingsTestCase(TestCase):
"""
Confirm reconcile_port_mappings and change logging behave identically for PortTemplateMapping.
"""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='testuser', password='pw')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
cls.device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
cls.front_port = FrontPortTemplate.objects.create(
device_type=cls.device_type, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=4
)
cls.rear_ports = [
RearPortTemplate.objects.create(
device_type=cls.device_type, name=f'Rear Port {i}', type=PortTypeChoices.TYPE_8P8C, positions=4
)
for i in range(1, 3)
]
def _desired(self, *pairs):
return [
{'front_port_position': fpp, 'rear_port_id': rp.pk, 'rear_port_position': rpp}
for fpp, rp, rpp in pairs
]
def _reconcile(self, desired):
request = _build_request(self.user)
with event_tracking(request):
reconcile_port_mappings(
PortTemplateMapping,
parent_field='front_port',
parent=self.front_port,
desired=desired,
)
def _mapping_changes(self, action=None):
changes = ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(PortTemplateMapping)
)
if action is not None:
changes = changes.filter(action=action)
return changes
def test_create_records_objectchange(self):
self._reconcile(self._desired((1, self.rear_ports[0], 1)))
mapping = PortTemplateMapping.objects.get(front_port=self.front_port)
self.assertEqual(mapping.device_type_id, self.device_type.pk)
self.assertEqual(self._mapping_changes(ObjectChangeActionChoices.ACTION_CREATE).count(), 1)
@tag('regression') # Ref: #22644
def test_noop_resave_writes_nothing(self):
desired = self._desired((1, self.rear_ports[0], 1))
self._reconcile(desired)
original_pk = PortTemplateMapping.objects.get(front_port=self.front_port).pk
ObjectChange.objects.all().delete()
self._reconcile(desired)
self.assertEqual(self._mapping_changes().count(), 0)
self.assertEqual(PortTemplateMapping.objects.get(front_port=self.front_port).pk, original_pk)
class PortMappingAPITestCase(APITestCase):
"""
Exercise the reconcile behaviour through PortSerializer.create()/update() over the REST API,
covering the model-instance -> _id normalization in PortSerializer._reconcile_mappings.
"""
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
site = Site.objects.create(name='Site 1', slug='site-1')
cls.device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
cls.rear_ports = [
RearPort.objects.create(
device=cls.device, name=f'Rear Port {i}', type=PortTypeChoices.TYPE_8P8C, positions=4
)
for i in range(1, 4)
]
# An existing front port with two mappings, for the update/PATCH tests.
cls.front_port = FrontPort.objects.create(
device=cls.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, positions=2
)
PortMapping.objects.bulk_create([
PortMapping(
device=cls.device, front_port=cls.front_port, front_port_position=1,
rear_port=cls.rear_ports[0], rear_port_position=1,
),
PortMapping(
device=cls.device, front_port=cls.front_port, front_port_position=2,
rear_port=cls.rear_ports[1], rear_port_position=1,
),
])
def _mapping_pks(self, front_port):
return set(PortMapping.objects.filter(front_port=front_port).values_list('pk', flat=True))
def _mapping_creates(self):
return ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(PortMapping),
action=ObjectChangeActionChoices.ACTION_CREATE,
)
def test_create_records_mappings_and_changelog(self):
# Confirms PortSerializer.create() + _reconcile_mappings normalization (rear_port instance ->
# rear_port_id) writes the mappings and records their ObjectChanges.
self.add_permissions('dcim.add_frontport', 'dcim.view_frontport', 'dcim.view_rearport', 'dcim.view_device')
data = {
'device': self.device.pk,
'name': 'Front Port 2',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
'rear_ports': [
{'position': 1, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 1},
{'position': 2, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 2},
],
}
response = self.client.post(reverse('dcim-api:frontport-list'), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
front_port = FrontPort.objects.get(pk=response.data['id'])
mappings = PortMapping.objects.filter(front_port=front_port).order_by('front_port_position')
self.assertEqual(mappings.count(), 2)
self.assertEqual(mappings[0].rear_port_id, self.rear_ports[2].pk)
self.assertEqual(mappings[1].rear_port_position, 2)
self.assertEqual(
self._mapping_creates().filter(changed_object_id__in=mappings.values_list('pk', flat=True)).count(), 2
)
def test_update_omitting_rear_ports_preserves_mappings(self):
# A PATCH that omits rear_ports must leave the existing mappings untouched.
self.add_permissions('dcim.change_frontport', 'dcim.view_frontport')
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.front_port.pk})
original_pks = self._mapping_pks(self.front_port)
response = self.client.patch(url, {'description': 'Updated'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(self._mapping_pks(self.front_port), original_pks)
def test_update_rewires_mappings(self):
# Re-point both slots to new rear port pairs; reconcile deletes the old rows and creates the
# replacements.
self.add_permissions('dcim.change_frontport', 'dcim.view_frontport', 'dcim.view_rearport', 'dcim.view_device')
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.front_port.pk})
original_pks = self._mapping_pks(self.front_port)
data = {
'rear_ports': [
{'position': 1, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 3},
{'position': 2, 'rear_port': self.rear_ports[2].pk, 'rear_port_position': 4},
],
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
mappings = PortMapping.objects.filter(front_port=self.front_port).order_by('front_port_position')
self.assertEqual([m.rear_port_id for m in mappings], [self.rear_ports[2].pk, self.rear_ports[2].pk])
self.assertEqual([m.rear_port_position for m in mappings], [3, 4])
self.assertTrue(self._mapping_pks(self.front_port).isdisjoint(original_pks))

View File

@ -1308,6 +1308,22 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# run base test
super().test_bulk_update_objects_with_permission()
def test_bulk_update_objects_without_change_permission(self):
# ModuleTypeImportView declares these as additional_permissions, so they're required to reach the view
self.add_permissions(
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
)
# run base test
super().test_bulk_update_objects_without_change_permission()
@tag('regression')
def test_bulk_import_objects_with_permission(self):
self.add_permissions(
@ -4205,6 +4221,110 @@ class CableTestCase(
self.assertEqual(self._get_queryset().count(), initial_count)
#
# Connections
#
class ConnectionsListViewTestCaseMixin:
"""
Shared behavior for the read-only connection list views.
These views list components whose cable paths are complete, but their URL names
do not follow the <model>_list pattern assumed by ModelViewTestCase.
"""
url_base = None
def _get_base_url(self):
return self.url_base
def _get_queryset(self):
return self.model.objects.filter(_path__is_complete=True)
class ConsoleConnectionsListViewTestCase(
ConnectionsListViewTestCaseMixin,
ViewTestCases.ListObjectsViewTestCase
):
model = ConsolePort
url_base = 'dcim:console_connections_{}'
query_count_model_label = 'consoleconnection'
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
peer_device = create_test_device('Device 2')
console_ports = ConsolePort.objects.bulk_create((
ConsolePort(device=device, name='Console Port 1'),
ConsolePort(device=device, name='Console Port 2'),
ConsolePort(device=device, name='Console Port 3'),
))
console_server_ports = ConsoleServerPort.objects.bulk_create((
ConsoleServerPort(device=peer_device, name='Console Server Port 1'),
ConsoleServerPort(device=peer_device, name='Console Server Port 2'),
ConsoleServerPort(device=peer_device, name='Console Server Port 3'),
))
for console_port, console_server_port in zip(console_ports, console_server_ports):
Cable(a_terminations=[console_port], b_terminations=[console_server_port]).save()
class PowerConnectionsListViewTestCase(
ConnectionsListViewTestCaseMixin,
ViewTestCases.ListObjectsViewTestCase
):
model = PowerPort
url_base = 'dcim:power_connections_{}'
query_count_model_label = 'powerconnection'
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
peer_device = create_test_device('Device 2')
power_ports = PowerPort.objects.bulk_create((
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
PowerPort(device=device, name='Power Port 3'),
))
power_outlets = PowerOutlet.objects.bulk_create((
PowerOutlet(device=peer_device, name='Power Outlet 1'),
PowerOutlet(device=peer_device, name='Power Outlet 2'),
PowerOutlet(device=peer_device, name='Power Outlet 3'),
))
for power_port, power_outlet in zip(power_ports, power_outlets):
Cable(a_terminations=[power_port], b_terminations=[power_outlet]).save()
class InterfaceConnectionsListViewTestCase(
ConnectionsListViewTestCaseMixin,
ViewTestCases.ListObjectsViewTestCase
):
model = Interface
url_base = 'dcim:interface_connections_{}'
query_count_model_label = 'interfaceconnection'
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
peer_device = create_test_device('Device 2')
interfaces = Interface.objects.bulk_create((
Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
))
peer_interfaces = Interface.objects.bulk_create((
Interface(device=peer_device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=peer_device, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=peer_device, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
))
for interface, peer_interface in zip(interfaces, peer_interfaces):
Cable(a_terminations=[interface], b_terminations=[peer_interface]).save()
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis

View File

@ -166,12 +166,15 @@ def update_interface_bridges(device, interface_templates, module=None):
Interface = apps.get_model('dcim', 'Interface')
for interface_template in interface_templates.exclude(bridge=None):
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
interface = Interface.objects.get(
device=device,
name=interface_template.resolve_name(module=module, device=device)
)
if interface_template.bridge:
interface.bridge = Interface.objects.get(
device=device,
name=interface_template.bridge.resolve_name(module=module)
name=interface_template.bridge.resolve_name(module=module, device=device)
)
interface.full_clean()
interface.save()
@ -196,8 +199,8 @@ def create_port_mappings(device, device_or_module_type, module=None):
# Replicate PortMappings
mappings = []
for template in templates:
front_port = front_ports.get(template.front_port.resolve_name(module=module))
rear_port = rear_ports.get(template.rear_port.resolve_name(module=module))
front_port = front_ports.get(template.front_port.resolve_name(module=module, device=device))
rear_port = rear_ports.get(template.rear_port.resolve_name(module=module, device=device))
mappings.append(
PortMapping(
device_id=front_port.device_id,
@ -207,4 +210,53 @@ def create_port_mappings(device, device_or_module_type, module=None):
rear_port_position=template.rear_port_position,
)
)
# Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
PortMapping.objects.bulk_create(mappings)
def reconcile_port_mappings(mapping_model, parent_field, parent, desired):
"""
Reconcile a parent port's mappings against `desired`, writing only the difference so unchanged
mappings keep their PK (and emit no changelog entry). Changed/removed rows are deleted before
replacements are created, all in one transaction, so position swaps don't trip the unique
constraint. Per-row create()/delete() let the change-logging signals fire naturally.
Args:
mapping_model: PortMapping or PortTemplateMapping.
parent_field: 'front_port' or 'rear_port' the side being edited; its '<parent_field>_position'
is each mapping's stable identity within the set.
parent: the parent instance (FrontPort/RearPort or their templates).
desired: iterable of dicts of mapping field values EXCLUDING the parent FK, using '<field>_id'
for the opposite-port FK, e.g. {'front_port_position': 1, 'rear_port_id': 5,
'rear_port_position': 2}. save() derives device/device_type/module_type from the front port.
"""
key_field = f'{parent_field}_position'
other_field = 'rear_port' if parent_field == 'front_port' else 'front_port'
value_fields = (f'{other_field}_id', f'{other_field}_position')
def target(source):
# The comparable "value" of a mapping: the opposite port and its position. Two mappings with
# the same parent-side position but a different target represent a re-pointing of that slot.
get = source.get if isinstance(source, dict) else lambda f: getattr(source, f)
return tuple(get(f) for f in value_fields)
desired_by_key = {d[key_field]: d for d in desired}
with transaction.atomic(using=router.db_for_write(mapping_model)):
# Lock the parent's existing mappings for the duration of the reconcile. Two requests editing
# the same port would otherwise read the same snapshot and race, the second colliding on a
# unique constraint when it recreates rows the first has already committed.
existing = {
getattr(m, key_field): m
for m in mapping_model.objects.filter(**{parent_field: parent}).select_for_update()
}
# Delete rows that no longer exist or whose target changed (before creating, to free the slots)
for key, mapping in existing.items():
if key not in desired_by_key or target(mapping) != target(desired_by_key[key]):
mapping.delete()
# Create rows that are new or whose target changed
for key, attrs in desired_by_key.items():
if key not in existing or target(existing[key]) != target(attrs):
mapping_model.objects.create(**{parent_field: parent, **attrs})

View File

@ -15,9 +15,19 @@ __all__ = (
'ConfigContextQuerySetMixin',
'ConfigTemplateRenderMixin',
'RenderConfigMixin',
'SharedObjectQuerySetMixin',
)
class SharedObjectQuerySetMixin:
"""
Restrict the queryset to shared objects, or those owned by the current user, unless the user is a superuser.
This mirrors the visibility enforced in the UI by extras.utils.SharedObjectViewMixin.
"""
def get_queryset(self):
return super().get_queryset().restrict_to_shared(self.request.user)
class ConfigContextQuerySetMixin:
"""
Used by viewsets for config context models (Device, VirtualMachine).

View File

@ -38,6 +38,15 @@ class JournalEntrySerializer(NetBoxModelSerializer):
]
brief_fields = ('id', 'url', 'display', 'created')
def get_fields(self):
fields = super().get_fields()
# Make created_by field read-only if updating an existing JournalEntry.
if self.instance is not None:
fields['created_by'].read_only = True
return fields
def validate(self, data):
# Validate that the parent object exists

View File

@ -1,7 +1,7 @@
import logging
from django.core.files.storage import storages
from django.db import IntegrityError
from django.db import IntegrityError, router, transaction
from django.utils.translation import gettext_lazy as _
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
@ -38,11 +38,21 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
file = data.pop('file', None)
data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
# Reject duplicates before writing to storage so a failed upload can't touch the existing file
if file is not None and ScriptModule.objects.filter(
file_root=ManagedFileRootPathChoices.SCRIPTS, file_path=file.name
).exists():
raise serializers.ValidationError(_("A script module with this file name already exists."))
if self.instance is None:
# Reject duplicates before writing to storage so a failed upload can't touch the existing file
if file is not None and ScriptModule.objects.filter(
file_root=ManagedFileRootPathChoices.SCRIPTS, file_path=file.name
).exists():
raise serializers.ValidationError(_("A script module with this file name already exists."))
elif file is None:
# Replacing a module's content requires a file upload, even for a partial update
raise serializers.ValidationError({'file': _("This field is required.")})
elif file.name != self.instance.file_path:
raise serializers.ValidationError({
'file': _(
"The uploaded file name must match the existing file path ({path})."
).format(path=self.instance.file_path)
})
data = super().validate(data)
data.pop('file_root', None)
@ -85,6 +95,35 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
except Exception:
logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
def update(self, instance, validated_data):
file = validated_data.pop('file')
storage = storages.create_storage(storages.backends["scripts"])
# Overwrite the existing file in place, keeping file_path stable
file.seek(0)
saved_path = storage.save(instance.file_path, file)
if saved_path != instance.file_path:
# The backend saved under an alternate name instead of overwriting; drop the orphan and reject
try:
storage.delete(saved_path)
except Exception:
logger.warning(f"Failed to delete orphaned script file '{saved_path}' from storage.")
raise serializers.ValidationError({
'file': _(
"The scripts storage backend did not overwrite the existing file. Ensure the "
"backend is configured to allow overwrites."
)
})
# Discard any cached class discovery so save() re-syncs from the new content
instance.__dict__.pop('module_scripts', None)
instance.last_updated = local_now()
# Keep Script row sync all-or-nothing; the storage write above cannot join the transaction
with transaction.atomic(using=router.db_for_write(ScriptModule)):
instance.save()
return instance
class ScriptSerializer(ValidatedModelSerializer):
description = serializers.SerializerMethodField(read_only=True)

View File

@ -6,12 +6,13 @@ from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ModelViewSet
from core.choices import ManagedFileRootPathChoices
from extras import filtersets
from extras.jobs import ScriptJob
from extras.models import *
@ -27,7 +28,7 @@ from utilities.request import copy_safe_request
from utilities.rqworker import any_workers_for_queue
from . import serializers
from .mixins import ConfigTemplateRenderMixin
from .mixins import ConfigTemplateRenderMixin, SharedObjectQuerySetMixin
class ExtrasRootView(APIRootView):
@ -126,7 +127,7 @@ class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
# Saved filters
#
class SavedFilterViewSet(NetBoxModelViewSet):
class SavedFilterViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = SavedFilter.objects.all()
serializer_class = serializers.SavedFilterSerializer
@ -137,7 +138,7 @@ class SavedFilterViewSet(NetBoxModelViewSet):
# Table Configs
#
class TableConfigViewSet(NetBoxModelViewSet):
class TableConfigViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = TableConfig.objects.all()
serializer_class = serializers.TableConfigSerializer
@ -283,9 +284,28 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
# Scripts
#
class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, BaseViewSet):
queryset = ScriptModule.objects.all()
class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMixin, BaseViewSet):
queryset = ScriptModule.objects.filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
serializer_class = serializers.ScriptModuleSerializer
lookup_value_regex = '[^/]+' # Allow dots
def get_object(self):
"""
Retrieve a ScriptModule by numeric ID or by file name (e.g. my_script.py).
"""
queryset = self.filter_queryset(self.get_queryset())
lookup = self.kwargs.get(self.lookup_url_kwarg or self.lookup_field, '')
# Support lookup by numeric PK or by file_path. Treat all-decimal values as PKs
# to preserve normal detail-route behavior; otherwise resolve the value as a
# script module filename, e.g. "myscript.py".
if lookup.isdecimal():
obj = get_object_or_404(queryset, pk=int(lookup))
else:
obj = get_object_or_404(queryset, file_path=lookup)
self.check_object_permissions(self.request, obj)
return obj
@extend_schema_view(

View File

@ -28,6 +28,11 @@ IMAGE_ATTACHMENT_IMAGE_FORMATS = {
# Template Export
DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
# Scripts
# Prefix applied to dynamically-loaded script/report module names so that a script whose filename
# matches a core app or package (e.g. "circuits.py") cannot shadow it in sys.modules.
SCRIPT_MODULE_NAME_PREFIX = '_netbox_script_module_'
# Webhooks
HTTP_CONTENT_TYPE_JSON = 'application/json'

View File

@ -2,7 +2,7 @@ import logging
import uuid
from functools import cached_property
from hashlib import sha256
from urllib.parse import urlencode
from urllib.parse import urlencode, urlparse
import feedparser
import requests
@ -16,7 +16,9 @@ from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.choices import BookmarkOrderingChoices
from netbox.config import get_config
from utilities.choices import Choice
from utilities.html import clean_html
from utilities.object_types import object_type_identifier, object_type_name
from utilities.permissions import get_permission_for_model
from utilities.proxy import resolve_proxies
@ -357,7 +359,9 @@ class RSSFeedWidget(DashboardWidget):
def cache_key(self):
url = self.config['feed_url']
url_checksum = sha256(url.encode('utf-8')).hexdigest()
return f'dashboard_rss_{url_checksum}'
# The version segment invalidates entries cached by a pre-sanitization release: such
# entries live under the old key and are never read, so they can't be served unsanitized.
return f'dashboard_rss_2_{url_checksum}'
def get_feed(self):
if self.config.get('requires_internet') and settings.ISOLATED_DEPLOYMENT:
@ -365,7 +369,8 @@ class RSSFeedWidget(DashboardWidget):
'isolated_deployment': True,
}
# Fetch RSS content from cache if available
# Fetch RSS content from cache if available. Cached content is always sanitized before
# it is written (see below), so no sanitization is needed on read.
if feed_content := cache.get(self.cache_key):
return {
'feed': feedparser.FeedParserDict(feed_content),
@ -391,6 +396,8 @@ class RSSFeedWidget(DashboardWidget):
# Cap number of entries
max_entries = self.config.get('max_entries')
feed['entries'] = feed['entries'][:max_entries]
# Sanitize feed-controlled content before caching/rendering
self.sanitize_entries(feed['entries'])
# Cache the feed content
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
@ -398,6 +405,27 @@ class RSSFeedWidget(DashboardWidget):
'feed': feed,
}
@staticmethod
def sanitize_entries(entries):
"""
Sanitize feed-controlled entry content in place. The feed URL is untrusted external
content, so we must guard against dangerous URL schemes (e.g. javascript:) in entry
links and sanitize entry summaries as defense-in-depth.
"""
allowed_schemes = get_config().ALLOWED_URL_SCHEMES
for entry in entries:
# Blank any link whose scheme isn't permitted (blocks javascript:, data:, etc.).
# This is the load-bearing control: the template renders entry.link into an href.
if link := entry.get('link'):
result = urlparse(link)
if result.scheme and result.scheme.lower() not in allowed_schemes:
entry['link'] = ''
# Sanitize the summary HTML as defense-in-depth. The template renders entry.summary
# with auto-escaping (not |safe), so this is not currently load-bearing; it guards
# against a future change that renders the summary as markup.
if summary := entry.get('summary'):
entry['summary'] = clean_html(summary, allowed_schemes)
@register_widget
class BookmarksWidget(DashboardWidget):

View File

@ -344,6 +344,12 @@ class JournalEntryImportForm(NetBoxModelImportForm):
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# If not creating a new JournalEntry, disable the created_by field
if self.instance and not self.instance._state.adding:
self.fields['created_by'].disabled = True
class NotificationGroupImportForm(CSVModelForm):
users = CSVModelMultipleChoiceField(

View File

@ -82,7 +82,9 @@ class JournalEntriesMixin:
@strawberry.type
class TagsMixin:
tags: list[Annotated['TagType', strawberry.lazy('.types')]]
tags: list[Annotated['TagType', strawberry.lazy('.types')]] = strawberry_django.field(
prefetch_related=['tags'],
)
@strawberry.type

View File

@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry.scalars import JSON
from strawberry.types import Info
from core.graphql.mixins import SyncedDataMixin
from extras import models
@ -48,6 +49,17 @@ __all__ = (
)
class SharedObjectMixin:
"""
Restrict the queryset to shared objects, or those owned by the current user, unless the user is a superuser.
This mirrors the visibility enforced in the UI (extras.utils.SharedObjectViewMixin) and the REST API.
"""
@classmethod
def get_queryset(cls, queryset, info: Info, **kwargs):
queryset = super().get_queryset(queryset, info, **kwargs)
return queryset.restrict_to_shared(info.context.request.user)
@strawberry_django.type(
models.ConfigContextProfile,
fields='__all__',
@ -184,7 +196,7 @@ class NotificationGroupType(ObjectType):
filters=SavedFilterFilter,
pagination=True
)
class SavedFilterType(OwnerMixin, ObjectType):
class SavedFilterType(SharedObjectMixin, OwnerMixin, ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@ -203,7 +215,7 @@ class SubscriptionType(ObjectType):
filters=TableConfigFilter,
pagination=True
)
class TableConfigType(ObjectType):
class TableConfigType(SharedObjectMixin, ObjectType):
object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None

View File

@ -1,4 +1,7 @@
import copy
import os
import re
import sys
import traceback
import jsonschema
@ -464,6 +467,16 @@ class ConfigTemplate(
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
def get_environment_params(self):
"""
Config templates render plain text (network configs, scripts), not HTML. Force
autoescape off so environment_params cannot enable it and create a latent XSS sink
if output is ever rendered in an HTML context.
"""
params = super().get_environment_params()
params['autoescape'] = False
return params
def format_render_error(self, exc):
"""
Return a formatted error string for a rendering exception. When debug is enabled, the full
@ -471,7 +484,23 @@ class ConfigTemplate(
is returned.
"""
if self.debug:
return ''.join(traceback.format_exception(exc))
# Strip deployment-specific path prefixes from File "..." lines to avoid disclosing
# the server's filesystem layout. install_root covers all NetBox source files plus
# any venv co-located inside the repo. When the venv lives outside the repo
# (the typical production pattern, e.g. ~/.venv/netbox/), sys.prefix differs from
# sys.base_prefix and the venv root is stripped separately so that the deployment
# user's home directory is not exposed. Stdlib paths not under either prefix are
# left as-is — they reveal only standard OS locations, not deployment structure.
install_root = os.path.dirname(settings.BASE_DIR) + os.sep
prefixes_to_strip = [install_root]
if sys.prefix != sys.base_prefix:
venv_root = sys.prefix + os.sep
if venv_root != install_root:
prefixes_to_strip.append(venv_root)
tb = ''.join(traceback.format_exception(exc))
for prefix in prefixes_to_strip:
tb = re.sub(r'(File ")' + re.escape(prefix), r'\1', tb)
return tb
if isinstance(exc, TemplateError):
parts = [f"{type(exc).__name__}: {exc}"]
if getattr(exc, 'name', None):

View File

@ -13,7 +13,7 @@ from django.utils.module_loading import import_string
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED
from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED, SCRIPT_MODULE_NAME_PREFIX
from extras.utils import filename_from_model, filename_from_object
from utilities.jinja2 import render_jinja2
@ -71,12 +71,16 @@ class PythonModuleMixin:
Load the module using importlib, but use a custom loader to use django-storages
instead of the file system.
"""
spec = importlib.util.spec_from_file_location(self.python_name, self.name)
# Load the module under a namespaced name rather than its bare name. Using the bare name
# (e.g. "circuits") would replace the like-named core app package in sys.modules, breaking
# app and migration graph resolution.
module_name = f'{SCRIPT_MODULE_NAME_PREFIX}{self.python_name}'
spec = importlib.util.spec_from_file_location(module_name, self.name)
if spec is None:
raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
loader = CustomStoragesLoader(self.name)
module = importlib.util.module_from_spec(spec)
sys.modules[self.python_name] = module
sys.modules[module_name] = module
loader.exec_module(module)
return module
@ -255,7 +259,7 @@ class RenderTemplateMixin(models.Model):
extension = f'.{self.file_extension}' if self.file_extension else ''
if self.file_name:
filename = self.file_name
elif queryset:
elif queryset is not None:
filename = filename_from_model(queryset.model)
elif context:
filename = filename_from_object(context)

View File

@ -18,6 +18,7 @@ from extras.choices import *
from extras.conditions import ConditionSet, InvalidCondition
from extras.constants import *
from extras.models.mixins import RenderTemplateMixin
from extras.querysets import SharedObjectQuerySet
from extras.utils import image_upload
from netbox.config import get_config
from netbox.events import get_event_type_choices
@ -519,6 +520,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
verbose_name=_('parameters')
)
objects = SharedObjectQuerySet.as_manager()
clone_fields = (
'object_types', 'weight', 'enabled', 'parameters',
)
@ -606,6 +609,8 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
null=True,
)
objects = SharedObjectQuerySet.as_manager()
clone_fields = ('object_type', 'table', 'enabled', 'shared', 'columns', 'ordering')
class Meta:

View File

@ -9,6 +9,7 @@ __all__ = (
'ConfigContextModelQuerySet',
'ConfigContextQuerySet',
'NotificationQuerySet',
'SharedObjectQuerySet',
)
@ -199,3 +200,20 @@ class NotificationQuerySet(RestrictedQuerySet):
Return only unread notifications.
"""
return self.filter(read__isnull=True)
class SharedObjectQuerySet(RestrictedQuerySet):
def restrict_to_shared(self, user):
"""
Restrict the queryset to objects which are shared or owned by the given user. Superusers are exempt;
anonymous users see only shared objects. This enforces consistent visibility across the UI, REST API,
and GraphQL API.
"""
if user.is_superuser:
return self
if user.is_anonymous:
return self.filter(shared=True)
return self.filter(
Q(shared=True) | Q(user=user)
)

View File

@ -12,6 +12,7 @@ from django.utils.translation import gettext as _
from core.choices import JobNotificationChoices
from extras.choices import LogLevelChoices
from extras.constants import SCRIPT_MODULE_NAME_PREFIX
from extras.models import ScriptModule
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@ -349,7 +350,12 @@ class BaseScript:
@classproperty
def module(self):
return self.__module__
# Strip the internal prefix applied when the module is loaded (see #22566) so that
# user-facing names (full_name, logger namespaces) reflect the original script filename.
name = self.__module__
if name.startswith(SCRIPT_MODULE_NAME_PREFIX):
name = name[len(SCRIPT_MODULE_NAME_PREFIX):]
return name
@classproperty
def class_name(self):
@ -361,7 +367,7 @@ class BaseScript:
@classmethod
def root_module(cls):
return cls.__module__.split(".")[0]
return cls.module.split(".")[0]
# Author-defined attributes

View File

@ -68,8 +68,8 @@ def custom_links(context, obj):
rendered['link'], rendered['link_target'], button_class, rendered['text']
)
except Exception as e:
template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" title="{e}">' \
f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" ' \
f'title="{escape(e)}"><i class="mdi mdi-alert"></i> {escape(cl.name)}</a>\n'
# Add grouped links to template
for group, links in group_names.items():
@ -84,8 +84,8 @@ def custom_links(context, obj):
)
except Exception as e:
links_rendered.append(
f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
f'<li><a class="dropdown-item" disabled="disabled" title="{escape(e)}"><span class="text-muted">'
f'<i class="mdi mdi-alert"></i> {escape(cl.name)}</span></a></li>'
)
if links_rendered:

View File

@ -1,4 +1,5 @@
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
@ -19,6 +20,6 @@ def render_widget(context, widget):
<span class="text-danger"><i class="mdi mdi-alert"></i></span>
{message1}
</p>
<p class="font-monospace ps-3">{e}</p>
<p class="font-monospace ps-3">{escape(e)}</p>
<p>{message2}</p>
""")

View File

@ -19,7 +19,7 @@
"exporttemplate:list_objects_with_permission": 19,
"imageattachment:api_list_objects": 11,
"imageattachment:list_objects_with_permission": 21,
"journalentry:api_list_objects": 15,
"journalentry:api_list_objects": 16,
"journalentry:list_objects_with_permission": 24,
"notification:api_list_objects": 12,
"notificationgroup:api_list_objects": 11,

View File

@ -1,10 +1,13 @@
import datetime
import hashlib
import io
import json
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import IntegrityError
from django.urls import reverse
from django.utils.timezone import make_aware, now
from rest_framework import status
@ -18,7 +21,7 @@ from extras.models import *
from extras.scripts import BooleanVar, IntegerVar, StringVar
from extras.scripts import Script as PythonClass
from users.constants import TOKEN_PREFIX
from users.models import Group, Token, User
from users.models import Group, ObjectPermission, Token, User
from utilities.tables import get_table_for_model
from utilities.testing import APITestCase, APIViewTestCases
@ -446,7 +449,24 @@ class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
custom_link.object_types.set([site_type])
class SavedFilterTestCase(APIViewTestCases.APIViewTestCase):
class SharedObjectAPITestMixin:
"""
Helpers for testing the shared/owner visibility enforced on SavedFilter and TableConfig.
"""
def _grant_view_permission_and_authenticate(self, user, model):
"""
Grant `user` an unconstrained view permission on `model`, create an API token, and return the
corresponding authentication header.
"""
obj_perm = ObjectPermission(name=f'{model._meta.model_name} view', actions=['view'])
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(model))
token = Token.objects.create(user=user)
return {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
class SavedFilterTestCase(SharedObjectAPITestMixin, APIViewTestCases.APIViewTestCase):
model = SavedFilter
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
create_data = [
@ -518,8 +538,55 @@ class SavedFilterTestCase(APIViewTestCases.APIViewTestCase):
for i, savedfilter in enumerate(saved_filters):
savedfilter.object_types.set([site_type])
def test_private_filter_not_visible_to_other_users(self):
"""
A private (shared=False) SavedFilter owned by another user must not be exposed via the REST API, even to
a user holding an unconstrained view permission.
"""
site_type = ObjectType.objects.get_for_model(Site)
owner = User.objects.create_user(username='filter-owner')
private_filter = SavedFilter.objects.create(
name='Private Filter',
slug='private-filter',
user=owner,
shared=False,
parameters={'status': ['active']},
)
private_filter.object_types.set([site_type])
class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
# Grant an unconstrained view permission (the common case)
self.add_permissions('extras.view_savedfilter')
# The private filter must not appear in the list
response = self.client.get(self._get_list_url(), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
returned_ids = [obj['id'] for obj in response.data['results']]
self.assertNotIn(private_filter.pk, returned_ids)
# The private filter must not be retrievable directly
response = self.client.get(self._get_detail_url(private_filter), **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
# The private filter must not be exposed via GraphQL either
query = '{ saved_filter_list { id } }'
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
returned_ids = [int(obj['id']) for obj in data['data']['saved_filter_list']]
self.assertNotIn(private_filter.pk, returned_ids)
# The owner, however, must still be able to access their own private filter
owner_header = self._grant_view_permission_and_authenticate(owner, SavedFilter)
response = self.client.get(self._get_detail_url(private_filter), **owner_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **owner_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
returned_ids = [int(obj['id']) for obj in data['data']['saved_filter_list']]
self.assertIn(private_filter.pk, returned_ids)
class TableConfigTestCase(SharedObjectAPITestMixin, APIViewTestCases.APIViewTestCase):
model = TableConfig
brief_fields = ['description', 'display', 'id', 'name', 'object_type', 'table', 'url']
bulk_update_data = {
@ -547,6 +614,7 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
object_type=site_type,
table=site_table_name,
user=users[0],
shared=True,
columns=['name', 'status'],
),
TableConfig(
@ -554,6 +622,7 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
object_type=site_type,
table=site_table_name,
user=users[1],
shared=True,
columns=['name', 'region'],
),
TableConfig(
@ -561,6 +630,7 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
object_type=site_type,
table=site_table_name,
user=users[2],
shared=True,
columns=['name', 'tenant'],
),
)
@ -589,6 +659,54 @@ class TableConfigTestCase(APIViewTestCases.APIViewTestCase):
},
]
def test_private_table_config_not_visible_to_other_users(self):
"""
A private (shared=False) TableConfig owned by another user must not be exposed via the REST API, even to
a user holding an unconstrained view permission.
"""
site_type = ObjectType.objects.get_for_model(Site)
site_table_name = get_table_for_model(Site).__name__
owner = User.objects.create_user(username='tableconfig-owner')
private_config = TableConfig.objects.create(
name='Private Table Config',
object_type=site_type,
table=site_table_name,
user=owner,
shared=False,
columns=['name', 'status'],
)
# Grant an unconstrained view permission (the common case)
self.add_permissions('extras.view_tableconfig')
# The private table config must not appear in the list
response = self.client.get(self._get_list_url(), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
returned_ids = [obj['id'] for obj in response.data['results']]
self.assertNotIn(private_config.pk, returned_ids)
# The private table config must not be retrievable directly
response = self.client.get(self._get_detail_url(private_config), **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
# The private table config must not be exposed via GraphQL either
query = '{ table_config_list { id } }'
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
returned_ids = [int(obj['id']) for obj in data['data']['table_config_list']]
self.assertNotIn(private_config.pk, returned_ids)
# The owner, however, must still be able to access their own private table config
owner_header = self._grant_view_permission_and_authenticate(owner, TableConfig)
response = self.client.get(self._get_detail_url(private_config), **owner_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
response = self.client.post(reverse('graphql'), data={'query': query}, format='json', **owner_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
returned_ids = [int(obj['id']) for obj in data['data']['table_config_list']]
self.assertIn(private_config.pk, returned_ids)
class BookmarkTestCase(
APIViewTestCases.GetObjectViewTestCase,
@ -810,6 +928,12 @@ class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
users = (
User(username='User 1'),
User(username='User 2'),
)
User.objects.bulk_create(users)
user = User.objects.first()
site = Site.objects.create(name='Site 1', slug='site-1')
@ -850,6 +974,25 @@ class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
},
]
def test_immutable_created_by(self):
"""
Verify that created_by can't be changed for existing objects
"""
entry = JournalEntry.objects.first()
created_by_before = entry.created_by_id
# select user different from the one currently set
change_user = User.objects.exclude(pk=created_by_before).only('id').first()
url = reverse('extras-api:journalentry-detail', kwargs={'pk': entry.pk})
self.add_permissions('extras.change_journalentry')
response = self.client.patch(url, {'created_by': change_user.id}, format='json', **self.header)
self.assertEqual(response.status_code, 200)
entry.refresh_from_db()
created_by_after = entry.created_by_id
self.assertEqual(created_by_before, created_by_after)
class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
model = ConfigContextProfile
@ -1623,17 +1766,22 @@ class NotificationTestCase(APIViewTestCases.APIViewTestCase):
class _InMemoryScriptStorage:
"""Stateful stand-in for the scripts storage backend; mimics allow_overwrite=True."""
"""Stateful stand-in for the scripts storage backend; mirrors its allow_overwrite option."""
def __init__(self):
def __init__(self, allow_overwrite=True):
self.files = {}
self.allow_overwrite = allow_overwrite
def save(self, name, content):
if not self.allow_overwrite and name in self.files:
name = f'{name}.1'
content.seek(0)
self.files[name] = content.read()
return name
def open(self, name, mode='rb'):
if name not in self.files:
raise FileNotFoundError(name)
return io.BytesIO(self.files[name])
def delete(self, name):
@ -1656,6 +1804,18 @@ class ScriptModuleTestCase(APITestCase):
super().setUp()
self.url = reverse('extras-api:scriptmodule-list') # /api/extras/scripts/upload/
@contextmanager
def _patched_script_storage(self, fake_storage):
"""Patch both storage entry points (serializer writes, module imports) to the given fake."""
with (
patch('extras.api.serializers_.scripts.storages') as serializer_storages,
patch('extras.models.mixins.storages') as module_storages,
):
serializer_storages.create_storage.return_value = fake_storage
serializer_storages.backends = {'scripts': {}}
module_storages.__getitem__.return_value = fake_storage
yield
def test_upload_script_module_without_permission(self):
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
upload_file = SimpleUploadedFile('test_upload.py', script_content, content_type='text/plain')
@ -1765,3 +1925,354 @@ class ScriptModuleTestCase(APITestCase):
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
response = self.client.post(self.url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_update_script_module(self):
"""A PATCH with a new file replaces the stored content and re-syncs the module's scripts."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
updated_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScriptV2(Script):\n def run(self, data, commit):\n return 'v2'\n"
)
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_update.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
module_id = response.data['id']
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
response = self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_update.py', updated_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['id'], module_id)
self.assertEqual(response.data['file_path'], 'zz_update.py')
self.assertIsNotNone(response.data['last_updated'])
self.assertEqual(fake_storage.files['zz_update.py'], updated_content)
# Script classes are re-synced from the new content
self.assertFalse(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScriptV2').exists())
self.assertEqual(ScriptModule.objects.filter(file_path='zz_update.py').count(), 1)
def test_update_script_module_without_file_fails(self):
"""A PATCH without a file upload is rejected and leaves the stored content unchanged."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_nofile.py', script_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
response = self.client.patch(detail_url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('file', response.data)
self.assertEqual(fake_storage.files['zz_nofile.py'], script_content)
def test_update_script_module_without_permission(self):
"""A PATCH without change permissions returns 403 and leaves the stored content unchanged."""
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_forbidden.py', script_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
response = self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_forbidden.py', script_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
self.assertEqual(fake_storage.files['zz_forbidden.py'], script_content)
def test_update_script_module_by_file_name(self):
"""A module can be updated by addressing it with its file name instead of its numeric ID."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
updated_content = original_content.replace(b"'v1'", b"'v2'")
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_by_name.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
module_id = response.data['id']
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': 'zz_by_name.py'})
response = self.client.put(
detail_url,
{'file': SimpleUploadedFile('zz_by_name.py', updated_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['id'], module_id)
self.assertEqual(fake_storage.files['zz_by_name.py'], updated_content)
def test_update_script_module_rejects_mismatched_file_name(self):
"""An update whose uploaded file name differs from the module's file path is rejected."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_mismatch.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
response = self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_other.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(fake_storage.files['zz_mismatch.py'], original_content)
self.assertNotIn('zz_other.py', fake_storage.files)
def test_update_script_module_storage_name_mismatch_fails(self):
"""If the storage backend saves under an alternate name, the update is rejected and rolled back."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
updated_content = original_content.replace(b"'v1'", b"'v2'")
fake_storage = _InMemoryScriptStorage(allow_overwrite=False)
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_suffix.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
module = ScriptModule.objects.get(file_path='zz_suffix.py')
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module.pk})
response = self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_suffix.py', updated_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Original file intact, alternate-name orphan removed
self.assertEqual(fake_storage.files['zz_suffix.py'], original_content)
self.assertNotIn('zz_suffix.py.1', fake_storage.files)
module.refresh_from_db()
self.assertEqual(module.file_path, 'zz_suffix.py')
def test_update_faulty_script_module_preserves_existing_module(self):
"""An update with invalid script content is rejected before storage or Script rows change."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
# 'extras.script' is invalid; the correct module is 'extras.scripts'
faulty_content = b"from extras.script import Script\nclass TestScript(Script):\n pass\n"
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_faulty_update.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
module_id = response.data['id']
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
response = self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_faulty_update.py', faulty_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(fake_storage.files['zz_faulty_update.py'], original_content)
self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
def test_update_script_module_not_found(self):
"""An update addressing a nonexistent module returns 404 for both lookup styles."""
self.add_permissions('extras.change_scriptmodule', 'core.change_managedfile')
for lookup in ('999999', 'zz_missing.py', '½'):
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': lookup})
response = self.client.patch(detail_url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
def test_update_script_module_via_put_without_file_fails(self):
"""A PUT without a file upload is rejected by the field-level required check."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
script_content = b"from extras.scripts import Script\nclass TestScript(Script):\n pass\n"
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_put_nofile.py', script_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
response = self.client.put(detail_url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('file', response.data)
self.assertEqual(fake_storage.files['zz_put_nofile.py'], script_content)
def test_update_script_module_rolls_back_scripts_on_save_failure(self):
"""A failed Script sync during an update rolls back all Script row changes."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
updated_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScriptV2(Script):\n def run(self, data, commit):\n return 'v2'\n"
)
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_rollback.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
module_id = response.data['id']
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': module_id})
# Fail the sync mid-way: the old Script row is already deleted when create() raises
with patch(
'extras.models.scripts.Script.objects.create',
side_effect=IntegrityError('Simulated database error'),
):
with self.assertRaises(IntegrityError):
self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_rollback.py', updated_content, content_type='text/plain')},
format='multipart',
**self.header,
)
# DB is all-or-nothing; storage keeps the new content and a retried update re-syncs the rows
self.assertTrue(Script.objects.filter(module_id=module_id, name='ProbeScript').exists())
self.assertFalse(Script.objects.filter(module_id=module_id, name='ProbeScriptV2').exists())
self.assertEqual(fake_storage.files['zz_rollback.py'], updated_content)
def test_update_script_module_with_missing_stored_file(self):
"""An update succeeds when the stored file is missing, re-creating it from the upload."""
self.add_permissions(
'extras.add_scriptmodule', 'core.add_managedfile',
'extras.change_scriptmodule', 'core.change_managedfile',
)
original_content = (
b"from extras.scripts import Script\n\n\n"
b"class ProbeScript(Script):\n def run(self, data, commit):\n return 'v1'\n"
)
updated_content = original_content.replace(b"'v1'", b"'v2'")
fake_storage = _InMemoryScriptStorage()
with self._patched_script_storage(fake_storage):
response = self.client.post(
self.url,
{'file': SimpleUploadedFile('zz_lost_file.py', original_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
detail_url = reverse('extras-api:scriptmodule-detail', kwargs={'pk': response.data['id']})
# Simulate a file removed from storage outside NetBox
del fake_storage.files['zz_lost_file.py']
response = self.client.patch(
detail_url,
{'file': SimpleUploadedFile('zz_lost_file.py', updated_content, content_type='text/plain')},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(fake_storage.files['zz_lost_file.py'], updated_content)

View File

@ -1569,7 +1569,7 @@ class CustomFieldAPITestCase(APITestCase):
site1 = Site.objects.get(name='Site 1')
vlans = VLAN.objects.all()[:3]
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
self.add_permissions('dcim.change_site')
self.add_permissions('dcim.change_site', 'ipam.view_vlan')
# Set related objects by PK
data = {

View File

@ -1,6 +1,10 @@
from django.test import TestCase, tag
from unittest.mock import MagicMock, patch
from extras.dashboard.widgets import ObjectListWidget
from django.core.cache import cache
from django.test import RequestFactory, TestCase, tag
from extras.dashboard.widgets import ObjectListWidget, RSSFeedWidget
from extras.templatetags.dashboard import render_widget
class ObjectListWidgetTestCase(TestCase):
@ -46,3 +50,107 @@ class ObjectListWidgetTestCase(TestCase):
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
rendered = widget.render(mock_request)
self.assertTrue('Unable to load content. Could not resolve list URL for:' in rendered)
class RSSFeedWidgetSanitizationTestCase(TestCase):
"""
Feed entry content is externally controlled and untrusted. Links must be validated against
ALLOWED_URL_SCHEMES so dangerous schemes (e.g. javascript:) cannot become clickable XSS sinks.
"""
@tag('regression')
def test_sanitize_entries_blanks_disallowed_schemes(self):
entries = [
{'link': 'javascript:alert(document.cookie)', 'title': 't1'},
{'link': 'JavaScript:alert(1)', 'title': 't2'}, # case-insensitive
{'link': 'data:text/html,<script>alert(1)</script>', 'title': 't3'},
{'link': 'vbscript:msgbox(1)', 'title': 't4'},
]
RSSFeedWidget.sanitize_entries(entries)
for entry in entries:
self.assertEqual(entry['link'], '', msg=f"Failed to blank {entry['title']}")
@tag('regression')
def test_sanitize_entries_preserves_allowed_links(self):
entries = [
{'link': 'https://example.com/post', 'title': 't1'},
{'link': 'http://example.com/post', 'title': 't2'},
{'link': 'mailto:user@example.com', 'title': 't3'},
{'link': '/relative/path', 'title': 't4'}, # schemeless relative link
]
expected = [e['link'] for e in entries]
RSSFeedWidget.sanitize_entries(entries)
self.assertEqual([e['link'] for e in entries], expected)
@tag('regression')
def test_sanitize_entries_cleans_summary_html(self):
entries = [
{'link': 'https://example.com', 'title': 't1', 'summary': '<b>ok</b><script>alert(1)</script>'},
]
RSSFeedWidget.sanitize_entries(entries)
self.assertNotIn('<script>', entries[0]['summary'])
self.assertIn('<b>ok</b>', entries[0]['summary'])
@tag('regression')
def test_get_feed_sanitizes_before_caching(self):
"""
Fetched feed content must be sanitized before it is rendered or written to the cache,
so a poisoned link is never stored or served.
"""
widget = RSSFeedWidget(config={
'feed_url': 'https://example.com/feed.xml',
'requires_internet': False,
'max_entries': 10,
'cache_timeout': 3600,
})
rss = (
b'<?xml version="1.0"?>'
b'<rss version="2.0"><channel><title>t</title>'
b'<link>http://example.com</link><description>d</description>'
b'<item><title>evil</title><link>javascript:alert(1)</link>'
b'<description>d</description></item>'
b'</channel></rss>'
)
mock_response = MagicMock()
mock_response.content = rss
with (
patch('extras.dashboard.widgets.requests.get', return_value=mock_response),
patch('extras.dashboard.widgets.resolve_proxies', return_value={}),
):
result = widget.get_feed()
# The rendered feed is sanitized...
self.assertEqual(result['feed']['entries'][0]['link'], '')
# ...and the cached copy is sanitized too (never stored poisoned).
cached = cache.get(widget.cache_key)
self.assertEqual(cached['entries'][0]['link'], '')
class RenderWidgetTemplateTagTestCase(TestCase):
def _make_context(self):
request = RequestFactory().get('/')
return {'request': request}
def test_render_widget_escapes_exception_html(self):
"""Exception text with HTML special chars must be escaped, not rendered as markup."""
class BrokenWidget:
def render(self, request):
raise Exception('<script>alert(1)</script>')
output = render_widget(self._make_context(), BrokenWidget())
self.assertIn('&lt;script&gt;', output)
self.assertNotIn('<script>', output)
def test_render_widget_escapes_exception_angle_brackets(self):
"""Angle brackets in exception messages are escaped."""
class BrokenWidget:
def render(self, request):
raise ValueError('invalid value: <bad>')
output = render_widget(self._make_context(), BrokenWidget())
self.assertIn('&lt;bad&gt;', output)
self.assertNotIn('<bad>', output)

View File

@ -190,7 +190,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
]
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
self.add_permissions('dcim.add_site', 'extras.view_tag')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 1)
@ -241,7 +241,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
},
]
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
self.add_permissions('dcim.add_site', 'extras.view_tag')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 3)
@ -275,7 +275,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
]
}
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.change_site')
self.add_permissions('dcim.change_site', 'extras.view_tag')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
@ -332,7 +332,7 @@ class EventRuleTestCase(RQQueueTestMixin, APITestCase):
},
]
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.change_site')
self.add_permissions('dcim.change_site', 'extras.view_tag')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)

View File

@ -1,15 +1,20 @@
import io
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.core.files.storage import Storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db import connection
from django.forms import ValidationError
from django.test import TestCase, tag
from django.test.utils import CaptureQueriesContext
from jinja2 import DebugUndefined, StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
from PIL import Image
@ -1200,6 +1205,32 @@ class ConfigTemplateDebugTestCase(TestCase):
with self.assertRaises(TemplateSyntaxError):
render_jinja2("{% debug %}", {}, debug=False)
def test_format_render_error_debug_redacts_install_path(self):
"""format_render_error() strips the repo install-path prefix from debug tracebacks."""
t = ConfigTemplate(name='redact-test', template_code='hello', debug=True)
try:
raise ValueError("deliberate test error")
except ValueError as exc:
result = t.format_render_error(exc)
install_root = os.path.dirname(settings.BASE_DIR) + os.sep
self.assertIn('Traceback', result)
self.assertNotIn(install_root, result)
# Also verify the venv prefix is stripped when running inside a virtualenv.
if sys.prefix != sys.base_prefix:
venv_root = sys.prefix + os.sep
if venv_root != install_root:
self.assertNotIn(venv_root, result)
def test_format_render_error_non_debug_returns_concise_message(self):
"""format_render_error() returns a one-line message (no traceback) when debug=False."""
t = ConfigTemplate(name='nodebug-test', template_code='hello', debug=False)
try:
raise TemplateError("bad template")
except TemplateError as exc:
result = t.format_render_error(exc)
self.assertNotIn('Traceback', result)
self.assertIn('TemplateError', result)
class JinjaEnvFilterTestCase(TestCase):
"""
@ -1310,6 +1341,24 @@ class RenderTemplateMixinRenderTestCase(TestCase):
self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
def test_configtemplate_autoescape_always_disabled(self):
"""
ConfigTemplate renders plain text (network configs, scripts); autoescape must stay off
even if environment_params explicitly requests it (#22652).
"""
t = ConfigTemplate(name='autoescape', template_code='{{ value }}', environment_params={'autoescape': True})
self.assertEqual(t.render({'value': '<script>'}), '<script>')
def test_exporttemplate_autoescape_is_configurable(self):
"""
Unlike ConfigTemplate, ExportTemplate output may legitimately be HTML, so an explicit
autoescape=True in environment_params must be honored rather than forced off.
"""
et = ExportTemplate(
name='autoescape', template_code='{{ value }}', environment_params={'autoescape': True}
)
self.assertEqual(et.render({'value': '<script>'}), '&lt;script&gt;')
def test_environment_params_undefined_path_import(self):
# Default Undefined renders nothing for a missing variable.
default = ConfigTemplate(name='default', template_code='{{ missing }}')
@ -1339,8 +1388,9 @@ class RenderTemplateMixinRenderTestCase(TestCase):
def test_get_environment_params_handles_none(self):
# The environment_params field may be cleared; ensure the mixin returns a dict (not None).
# ConfigTemplate always forces autoescape off (#22652).
t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
self.assertEqual(t.get_environment_params(), {})
self.assertEqual(t.get_environment_params(), {'autoescape': False})
def test_get_environment_params_resolves_path_imports(self):
t = ConfigTemplate(
@ -1398,6 +1448,37 @@ class RenderTemplateMixinResponseTestCase(TestCase):
response = t.render_to_response(queryset=Site.objects.all())
self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
def test_response_attachment_filename_from_empty_queryset(self):
"""An empty (but non-None) queryset must still yield a model-derived filename."""
t = ExportTemplate(
name='t',
template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
file_extension='txt',
as_attachment=True,
)
response = t.render_to_response(queryset=Site.objects.none())
self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
def test_response_attachment_does_not_force_queryset_evaluation(self):
"""A template that never references `queryset` must not force it to be evaluated."""
Site.objects.bulk_create([Site(name=f'Site {i}', slug=f'site-{i}') for i in range(5)])
t = ExportTemplate(
name='t',
template_code='static output', # deliberately does not reference `queryset`
file_extension='txt',
as_attachment=True,
)
with CaptureQueriesContext(connection) as ctx:
t.render_to_response(queryset=Site.objects.all())
table = Site._meta.db_table
site_queries = [q for q in ctx.captured_queries if table in q['sql']]
self.assertEqual(
site_queries, [],
f"render_to_response() queried {table} even though the template never "
f"references `queryset`:\n{site_queries}"
)
def test_response_attachment_filename_from_device_context(self):
t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
device = SimpleNamespace(name='router1')
@ -1666,9 +1747,11 @@ class JinjaEnvironmentParamsIntegrationTestCase(TestCase):
self.assertEqual(template.environment_params['undefined'], 'jinja2.StrictUndefined')
def test_none_environment_params(self):
# ConfigTemplate always forces autoescape off (#22652).
template = self._make_template(None)
self.assertEqual(template.get_environment_params(), {})
self.assertEqual(template.get_environment_params(), {'autoescape': False})
def test_empty_environment_params(self):
# ConfigTemplate always forces autoescape off (#22652).
template = self._make_template({})
self.assertEqual(template.get_environment_params(), {})
self.assertEqual(template.get_environment_params(), {'autoescape': False})

View File

@ -1,11 +1,16 @@
import io
import sys
from datetime import UTC, date, datetime
from decimal import Decimal
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPAddress, IPNetwork
from dcim.models import DeviceRole
from extras.constants import SCRIPT_MODULE_NAME_PREFIX
from extras.models import ScriptModule
from extras.scripts import *
CHOICES = (
@ -388,3 +393,49 @@ class ScriptVariablesTestCase(TestCase):
self.assertEqual(form.cleaned_data['var1'], input_datetime)
# Validate required=False works for this Var type
self.assertEqual(form.cleaned_data['var2'], None)
class ScriptModuleLoadingTestCase(TestCase):
def test_module_does_not_shadow_core_app(self):
"""
Loading a custom script whose filename matches a core app label must not replace that
app's package in sys.modules. Regression test for issue #22566.
"""
import circuits # The real core app package
script_content = (
b"from extras.scripts import Script\n\n\n"
b"class TestScript(Script):\n pass\n"
)
class _Storage:
def open(self, name, mode='rb'):
return io.BytesIO(script_content)
module = ScriptModule(file_root='scripts', file_path='circuits.py')
namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}circuits'
self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
with patch('extras.models.mixins.storages') as mock_storages:
mock_storages.__getitem__.return_value = _Storage()
loaded = module.get_module()
# The script module is registered under the private, namespaced key, and its own
# __name__ matches that key (i.e. sys.modules[module.__name__] resolves to the module)
self.assertIs(sys.modules[namespaced_key], loaded)
self.assertEqual(loaded.__name__, namespaced_key)
# The namespacing must not leak into the derived script name stored in the database
self.assertEqual(next(iter(module.module_scripts)), 'TestScript')
# Nor into the user-facing names exposed on the Script class (used for logger
# namespaces, page headers, etc.): these must reflect the original filename.
script_class = loaded.TestScript
self.assertEqual(script_class.module, 'circuits')
self.assertEqual(script_class.full_name, 'circuits.TestScript')
self.assertEqual(script_class.root_module(), 'circuits')
# The real circuits app must be untouched and remain an importable package
self.assertIs(sys.modules['circuits'], circuits)
self.assertTrue(hasattr(circuits, '__path__'))

View File

@ -50,7 +50,7 @@ class TaggedItemTestCase(APITestCase):
{"name": "New Tag"},
]
}
self.add_permissions('dcim.change_site')
self.add_permissions('dcim.change_site', 'extras.view_tag')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)

View File

@ -128,3 +128,64 @@ class CustomLinkRequestSanitizationTest(TestCase):
self.assertIn('/dcim/sites/1/', self.render_link('{{ request.path }}'))
self.assertIn('bar', self.render_link("{{ request.GET.get('foo') }}"))
self.assertIn('admin', self.render_link('{{ request.user }}'))
class CustomLinkRenderErrorEscapingTest(TestCase):
"""
When CustomLink.render() raises, the exception-fallback markup must escape the (attacker-controllable)
CustomLink name before it is returned via mark_safe() (see NB-3004).
"""
XSS_NAME = '<img src=x onerror=alert(1)>'
ESCAPED_NAME = '&lt;img src=x onerror=alert(1)&gt;'
@classmethod
def setUpTestData(cls):
cls.site = Site.objects.create(name='Site 1', slug='site-1')
def render(self, user):
request = RequestFactory().get('/')
request.user = user
context = {
'request': request,
'user': user,
'perms': PermWrapper(user),
}
return custom_links(context, self.site)
def make_user_with_view_permission(self, username):
user = User.objects.create_user(username=username)
permission = ObjectPermission.objects.create(name=f'{username} custom links', actions=['view'])
permission.object_types.set([ObjectType.objects.get_for_model(CustomLink)])
permission.users.set([user])
# Re-fetch to clear any cached permissions
return User.objects.get(pk=user.pk)
def test_render_error_escapes_name(self):
# A CustomLink whose render() raises must have its name escaped in the error fallback.
custom_link = CustomLink.objects.create(
name=self.XSS_NAME,
enabled=True,
link_text='{{ 1 / 0 }}', # Raises ZeroDivisionError during render
link_url='http://example.com/',
)
custom_link.object_types.set([ObjectType.objects.get_for_model(Site)])
rendered = self.render(self.make_user_with_view_permission('user1'))
self.assertNotIn(self.XSS_NAME, rendered)
self.assertIn(self.ESCAPED_NAME, rendered)
def test_render_error_escapes_grouped_name(self):
# The grouped-link error fallback must likewise escape the name.
custom_link = CustomLink.objects.create(
name=self.XSS_NAME,
enabled=True,
group_name='Group 1',
link_text='{{ 1 / 0 }}', # Raises ZeroDivisionError during render
link_url='http://example.com/',
)
custom_link.object_types.set([ObjectType.objects.get_for_model(Site)])
rendered = self.render(self.make_user_with_view_permission('user2'))
self.assertNotIn(self.XSS_NAME, rendered)
self.assertIn(self.ESCAPED_NAME, rendered)

View File

@ -6,7 +6,6 @@ from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
from django.core.files.storage import Storage, default_storage
from django.core.files.utils import validate_file_name
from django.db import models
from django.db.models import Q
from taggit.managers import _TaggableManager
from netbox.context import current_request
@ -32,14 +31,7 @@ class SharedObjectViewMixin:
"""
Return only shared objects, or those owned by the current user, unless this is a superuser.
"""
queryset = super().get_queryset(request)
if request.user.is_superuser:
return queryset
if request.user.is_anonymous:
return queryset.filter(shared=True)
return queryset.filter(
Q(shared=True) | Q(user=request.user)
)
return super().get_queryset(request).restrict_to_shared(request.user)
def filename_from_model(model: models.Model) -> str:

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import secrets
from utilities.secret_key import generate_secret_key
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
print(''.join(secrets.choice(charset) for _ in range(50)))
print(generate_secret_key())

View File

@ -69,7 +69,7 @@ class IPAddressStatusChoices(ChoiceSet):
Choice(STATUS_ACTIVE, _('Active'), color='blue', description=_('Provisioned and in use')),
Choice(STATUS_RESERVED, _('Reserved'), color='cyan', description=_('Designated for future use')),
Choice(STATUS_DEPRECATED, _('Deprecated'), color='red', description=_('No longer in use')),
Choice(STATUS_DHCP, _('DHCP'), color='green', description=_('Assigned dynamically via DHCP')),
Choice(STATUS_DHCP, _('DHCP'), color='purple', description=_('Assigned dynamically via DHCP')),
Choice(
STATUS_SLAAC,
_('SLAAC'),

View File

@ -1070,7 +1070,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
method='get_for_site'
)
available_on_device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
queryset=Device.objects.select_related('cluster'),
method='get_for_device'
)
available_on_virtualmachine = django_filters.ModelChoiceFilter(

View File

@ -0,0 +1,27 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0239_add_portmapping_objectchange'),
('ipam', '0092_iprange_host_indexes'),
]
operations = [
migrations.AlterField(
model_name='prefix',
name='_region',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.region'
),
),
migrations.AlterField(
model_name='prefix',
name='_site_group',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='dcim.sitegroup'
),
),
]

View File

@ -10,9 +10,9 @@ from utilities.migration import InstallDenormalizationTrigger
class Migration(migrations.Migration):
dependencies = [
('ipam', '0092_iprange_host_indexes'),
('ipam', '0093_alter_prefix__region_alter_prefix__site_group'),
# Source tables (dcim_site, dcim_location) must already exist.
('dcim', '0238_ltree_paths'),
('dcim', '0240_ltree_paths'),
]
operations = [

View File

@ -298,6 +298,18 @@ class VLANQuerySet(RestrictedQuerySet):
# Find all relevant VLANGroups
q = Q()
if device.cluster_id:
# The Device's physical scope is evaluated below. For valid assignments,
# the Cluster's physical scope is already represented by that hierarchy.
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
scope_id=device.cluster_id
)
if device.cluster.group_id:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
scope_id=device.cluster.group_id
)
if device.site.region:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),

View File

@ -2188,6 +2188,26 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'available_on_device': device_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
def test_available_on_device_cluster_scopes(self):
device = Device.objects.get(name='Device 1')
device.cluster = Cluster.objects.get(name='Cluster 1')
device.save(update_fields=('cluster',))
params = {'available_on_device': device.pk}
vlans = self.filterset(params, self.queryset).qs
# VLANs from groups scoped to the assigned cluster or its cluster group
self.assertIn(VLAN.objects.get(name='Cluster 1'), vlans)
self.assertIn(VLAN.objects.get(name='Cluster Group 1'), vlans)
# VLANs from groups scoped to unrelated clusters or cluster groups
self.assertNotIn(VLAN.objects.get(name='Cluster 2'), vlans)
self.assertNotIn(VLAN.objects.get(name='Cluster Group 2'), vlans)
# Site, location, rack and global availability is unchanged
self.assertEqual(
set(vlans.values_list('vid', flat=True)),
{1, 4, 7, 10, 13, 16, 19, 500, 1000}
)
def test_available_on_virtualmachine(self):
vm_id = VirtualMachine.objects.first().pk
params = {'available_on_virtualmachine': vm_id}

View File

@ -5,7 +5,7 @@ from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet
from dcim.models import Site, SiteGroup
from dcim.models import Location, Region, Site, SiteGroup
from ipam.choices import *
from ipam.constants import SERVICE_PORT_MAX, SERVICE_PORT_MIN
from ipam.models import *
@ -1262,6 +1262,80 @@ class PrefixTestCase(TestCase):
duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24'))
self.assertRaises(ValidationError, duplicate_prefix.clean)
# Regression test for #22682
def test_deleting_site_group_does_not_delete_prefix_scoped_to_member_site(self):
sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
site = Site.objects.create(name='Site 1', slug='site-1', group=sitegroup)
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/24'), scope=site)
sitegroup.delete()
site.refresh_from_db()
prefix.refresh_from_db()
self.assertIsNone(site.group)
self.assertEqual(prefix.scope, site)
self.assertIsNone(prefix._site_group_id)
# Regression test for #22682
def test_deleting_region_does_not_delete_prefix_scoped_to_member_site(self):
region = Region.objects.create(name='Region 1', slug='region-1')
site = Site.objects.create(name='Site 2', slug='site-2', region=region)
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.1.0/24'), scope=site)
region.delete()
site.refresh_from_db()
prefix.refresh_from_db()
self.assertIsNone(site.region)
self.assertEqual(prefix.scope, site)
self.assertIsNone(prefix._region_id)
# Regression test for #22682
def test_deleting_site_group_does_not_delete_prefix_scoped_to_member_location(self):
sitegroup = SiteGroup.objects.create(name='Site Group 3', slug='site-group-3')
site = Site.objects.create(name='Site 3', slug='site-3', group=sitegroup)
location = Location.objects.create(name='Location 1', slug='location-1', site=site)
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.4.0/24'), scope=location)
sitegroup.delete()
site.refresh_from_db()
prefix.refresh_from_db()
self.assertIsNone(site.group)
self.assertEqual(prefix.scope, location)
self.assertIsNone(prefix._site_group_id)
# Regression test for #22682
def test_deleting_region_does_not_delete_prefix_scoped_to_member_location(self):
region = Region.objects.create(name='Region 3', slug='region-3')
site = Site.objects.create(name='Site 4', slug='site-4', region=region)
location = Location.objects.create(name='Location 2', slug='location-2', site=site)
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.5.0/24'), scope=location)
region.delete()
site.refresh_from_db()
prefix.refresh_from_db()
self.assertIsNone(site.region)
self.assertEqual(prefix.scope, location)
self.assertIsNone(prefix._region_id)
def test_deleting_site_group_scoped_to_it_directly_still_deletes_prefix(self):
sitegroup = SiteGroup.objects.create(name='Site Group 2', slug='site-group-2')
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.2.0/24'), scope=sitegroup)
sitegroup.delete()
self.assertFalse(Prefix.objects.filter(pk=prefix.pk).exists())
def test_deleting_region_scoped_to_it_directly_still_deletes_prefix(self):
region = Region.objects.create(name='Region 2', slug='region-2')
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.3.0/24'), scope=region)
region.delete()
self.assertFalse(Prefix.objects.filter(pk=prefix.pk).exists())
class PrefixHierarchyTestCase(TestCase):
"""

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import EmptyResultSet
from django.db.models import Prefetch
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
@ -569,8 +570,13 @@ class ChildAvailabilityMixin:
@staticmethod
def _where_signature(queryset):
# query.where is Django-internal, but it is the closest signal for "narrowed by a filter".
return str(queryset.query.where)
# Compare compiled SQL rather than str(query.where): the WHERE tree embeds default
# object reprs (memory addresses) for permission-constraint subqueries, so two
# otherwise identical querysets built via restrict() never match (#22539).
try:
return queryset.query.get_compiler(using=queryset.db).as_sql()
except EmptyResultSet:
return None
def _set_children_filtered(self, is_filtered):
self._child_queryset_is_filtered = is_filtered

View File

@ -0,0 +1,8 @@
"""Allow `python -m netbox` to behave like the `netbox` console script."""
import sys
from .cli import main
if __name__ == '__main__':
sys.exit(main())

View File

@ -4,6 +4,7 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from netbox.api.fields import RelatedObjectCountField
from utilities.api import get_related_object_by_attrs
from .fields import NetBoxAPIHyperlinkedIdentityField, NetBoxURLHyperlinkedIdentityField
@ -49,7 +50,9 @@ class BaseModelSerializer(serializers.ModelSerializer):
# identifying a related object.
if self.nested:
queryset = self.Meta.model.objects.all()
return get_related_object_by_attrs(queryset, data)
request = self.context.get('request')
user = request.user if request else None
return get_related_object_by_attrs(queryset, data, user=user)
return super().to_internal_value(data)
@ -69,6 +72,15 @@ class BaseModelSerializer(serializers.ModelSerializer):
for field_name in set(self._omit_fields):
fields.pop(field_name, None)
# Related object counts are populated by annotations applied to the viewset's queryset, but these
# annotations are not applied when the object is represented as a nested (brief) related object. Omit
# these fields when serializing a nested object to avoid advertising fields that will never be populated
# (and which would otherwise be declared as required in the generated OpenAPI schema). See #22154.
if self.nested:
for field_name, field in list(fields.items()):
if isinstance(field, RelatedObjectCountField):
fields.pop(field_name)
return fields
@extend_schema_field(OpenApiTypes.STR)

View File

@ -22,6 +22,10 @@ class GenericObjectSerializer(serializers.Serializer):
object_id = serializers.IntegerField()
object = serializers.SerializerMethodField(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._serializer_cache = {}
def to_internal_value(self, data):
data = super().to_internal_value(data)
model = data['object_type'].model_class()
@ -40,5 +44,7 @@ class GenericObjectSerializer(serializers.Serializer):
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, obj):
serializer = get_serializer_for_model(obj)
return serializer(obj, nested=True, context=self.context).data
if obj.__class__ not in self._serializer_cache:
self._serializer_cache[obj.__class__] = get_serializer_for_model(obj)(nested=True, context=self.context)
serializer = self._serializer_cache[obj.__class__]
return serializer.to_representation(obj)

View File

@ -17,7 +17,9 @@ class WritableNestedSerializer(BaseModelSerializer):
"""
def to_internal_value(self, data):
queryset = self.Meta.model.objects.all()
return get_related_object_by_attrs(queryset, data)
request = self.context.get('request')
user = request.user if request else None
return get_related_object_by_attrs(queryset, data, user=user)
# Declared here for use by PrimaryModelSerializer

View File

@ -1,14 +1,15 @@
import logging
from collections import defaultdict
from django.apps import apps
from django.conf import settings
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.backends import RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from netbox.settings_utils import load_ldap_config
from users.constants import CONSTRAINT_TOKEN_USER
from users.models import Group, ObjectPermission, User
from utilities.permissions import (
@ -137,12 +138,18 @@ class ObjectPermissionMixin:
if obj is None:
return True
# Sanity check: Ensure that the requested permission applies to the specified object
model = obj._meta.concrete_model
if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(_("Invalid permission {permission} for model {model}").format(
permission=perm, model=model
))
# Sanity check: the permission must apply to the object's model. Permissions may name proxy
# models, so compare concrete models and evaluate constraints via the permission model's manager.
try:
permission_model = apps.get_model(app_label, model_name)
except LookupError:
logger = logging.getLogger('netbox.auth.ObjectPermissionBackend')
logger.warning(f"Permission {perm} does not reference a valid model")
return False
if permission_model._meta.concrete_model is not obj._meta.concrete_model:
logger = logging.getLogger('netbox.auth.ObjectPermissionBackend')
logger.debug(f"Permission {perm} is not valid for {obj._meta.label_lower} objects")
return False
# Compile a QuerySet filter that matches all instances of the specified model
tokens = {
@ -153,7 +160,7 @@ class ObjectPermissionMixin:
# Permission to perform the requested action on the object depends on whether the specified object matches
# the specified constraints. Note that this check is made against the *database* record representing the object,
# not the instance itself.
return model.objects.filter(qs_filter, pk=obj.pk).exists()
return permission_model.objects.filter(qs_filter, pk=obj.pk).exists()
class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
@ -338,15 +345,10 @@ class LDAPBackend:
)
raise e
try:
from netbox import ldap_config
except ModuleNotFoundError as e:
if getattr(e, 'name') == 'ldap_config':
raise ImproperlyConfigured(
"LDAP configuration file not found: Check that ldap_config.py has been created alongside "
"configuration.py."
)
raise e
ldap_config = load_ldap_config(
settings.CONFIGURATION_DIR,
allow_legacy_fallback=settings.NETBOX_INSTALL_MODE == 'checkout',
)
try:
getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
@ -358,11 +360,11 @@ class LDAPBackend:
obj = NBLDAPBackend()
# Read LDAP configuration parameters from ldap_config.py instead of settings.py
settings = LDAPSettings()
ldap_settings = LDAPSettings()
for param in dir(ldap_config):
if param.startswith(settings._prefix):
setattr(settings, param[10:], getattr(ldap_config, param))
obj.settings = settings
if param.startswith(ldap_settings._prefix):
setattr(ldap_settings, param[10:], getattr(ldap_config, param))
obj.settings = ldap_settings
# Optionally disable strict certificate checking
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):

98
netbox/netbox/cli.py Normal file
View File

@ -0,0 +1,98 @@
"""Console entry point for pip-installed NetBox."""
import argparse
import os
import sys
from importlib.metadata import PackageNotFoundError, version
# Commands handled here must not require Django settings. These names are intentionally
# reserved by the console wrapper and are never dispatched to Django management commands.
# 'setup' is not listed: the early-dispatch branch in main() owns it and always returns
# before this tuple is consulted.
_RESERVED_COMMANDS = ('secret-key', 'version')
_EPILOG = """Any other command is dispatched to the Django management commands, which
require a valid NetBox configuration, e.g.:
{prog} upgrade
{prog} check
{prog} createsuperuser
Run "{prog} help" (once configured) for the full management command listing."""
def _prog():
if sys.argv and sys.argv[0]:
name = os.path.basename(sys.argv[0])
# `python -m netbox` executes __main__.py; show the user-facing name instead.
if name != '__main__.py':
return name
return 'netbox'
def _print_version():
try:
print(version('netbox'))
except PackageNotFoundError: # pragma: no cover - only in a non-installed checkout
print('unknown')
def _build_parser(prog):
parser = argparse.ArgumentParser(
prog=prog,
description='NetBox command line interface.',
epilog=_EPILOG.format(prog=prog),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(
dest='command',
title='pre-configuration commands (no NetBox configuration required)',
)
subparsers.add_parser(
'version', help='Print the installed NetBox package version.',
description='Print the installed NetBox package version.')
subparsers.add_parser(
'setup', add_help=False,
help='Create the local configuration files for a pip-installed instance.')
subparsers.add_parser(
'secret-key', help='Generate a new 50-character SECRET_KEY value.',
description='Generate a new 50-character SECRET_KEY value.')
return parser
def main(argv=None):
prog = _prog()
args = list(sys.argv[1:] if argv is None else argv)
# `setup` owns its own parser (netbox.scaffold); dispatch before the wrapper parser.
if args and args[0] == 'setup':
# Deferred so the command works before Django or a configuration exists.
from netbox.scaffold import main as setup_main
return setup_main(args[1:], prog=f'{prog} setup')
if not args:
_build_parser(prog).print_help()
return 0
if args[0] in _RESERVED_COMMANDS or args[0] in ('-h', '--help', '--version'):
parser = _build_parser(prog)
if args[0] == '--version':
args = ['version', *args[1:]]
try:
options = parser.parse_args(args)
except SystemExit as e: # argparse already printed help (0) or an error (2)
return int(e.code or 0)
if options.command == 'version':
_print_version()
elif options.command == 'secret-key':
# Deferred so the command works before Django or a configuration exists.
from utilities.secret_key import generate_secret_key
print(generate_secret_key())
return 0
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netbox.settings')
# Deferred on purpose: Django must not import until DJANGO_SETTINGS_MODULE is set.
from django.core.management import execute_from_command_line
execute_from_command_line([prog, *args])
return 0

View File

@ -179,7 +179,7 @@ LOGOUT_REDIRECT_URL = 'home'
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
# the default value of this setting is derived from the installed location.
# MEDIA_ROOT = '/opt/netbox/netbox/media'
# MEDIA_ROOT = '/path/to/netbox/media'
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
METRICS_ENABLED = False
@ -214,14 +214,14 @@ RELEASE_CHECK_URL = None
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# REPORTS_ROOT = '/path/to/netbox/reports'
# Maximum execution time for background tasks, in seconds.
RQ_DEFAULT_TIMEOUT = 300
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
# SCRIPTS_ROOT = '/path/to/netbox/scripts'
# The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid'

View File

@ -108,9 +108,18 @@ class BaseFilterSet(django_filters.FilterSet):
# Apply any referenced SavedFilters
if data and ('filter' in data or 'filter_id' in data):
data = data.copy() # Get a mutable copy
# Coerce filter_id values to integers, ignoring any which are not valid (see #22568)
filter_ids = []
for f_id in data.pop('filter_id', []):
try:
filter_ids.append(int(f_id))
except (ValueError, TypeError):
pass
saved_filters = SavedFilter.objects.filter(
Q(slug__in=data.pop('filter', [])) |
Q(pk__in=data.pop('filter_id', []))
Q(pk__in=filter_ids)
)
for sf in saved_filters:
for key, value in sf.parameters.items():

88
netbox/netbox/scaffold.py Normal file
View File

@ -0,0 +1,88 @@
"""`netbox setup`: create the local configuration files for a pip-installed NetBox instance.
Runs before Django or a local configuration exists (dispatched from the `netbox` console
script, netbox.cli). Scaffolds conf/__init__.py and conf/configuration.py (copied verbatim
from the bundled configuration_example.py template) and an empty local_requirements.txt,
then copies the bundled deployment examples (gunicorn, systemd units, nginx, apache, uwsgi,
netbox.env) unmodified into <target>/contrib/. Nothing is generated or rewritten; adapting
and installing the examples (paths, systemd, the web server) remains the administrator's
job. Existing files are never overwritten.
"""
import argparse
import sys
from importlib.resources import files
from pathlib import Path
def _bundled_data_dir():
return files('netbox') / '_data'
def _config_template():
return files('netbox') / 'configuration_example.py'
def _contrib_dir():
return files('netbox') / '_data' / 'contrib'
def _write(destination, data):
if destination.exists():
print(f'Skipping existing {destination}')
return False
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
print(f'Wrote {destination}')
return True
def scaffold_instance(target):
target = Path(target)
conf = target / 'conf'
items = [
(conf / '__init__.py', b''),
(conf / 'configuration.py', _config_template().read_bytes()),
# An empty local_requirements.txt makes the optional plugin step discoverable and
# the documented "pip install -r local_requirements.txt" safe.
(target / 'local_requirements.txt', b''),
# Bundled deployment examples, copied byte-verbatim; adapting them is the admin's job.
# is_file() skips __pycache__: pip's post-install bytecode compilation of gunicorn.py
# (the only .py file among the examples) leaves one alongside the real files.
*(
(target / 'contrib' / example.name, example.read_bytes())
for example in sorted(_contrib_dir().iterdir(), key=lambda entry: entry.name)
if example.is_file()
),
]
return [str(destination) for destination, data in items if _write(destination, data)]
def main(argv=None, *, prog='netbox setup'):
parser = argparse.ArgumentParser(
prog=prog,
description=(
'Create the local configuration files for a pip-installed NetBox instance '
'(conf/configuration.py copied verbatim from the bundled template, plus an empty '
'local_requirements.txt), and copy the bundled deployment examples (gunicorn, '
'systemd units, nginx, apache, uwsgi, netbox.env) unmodified into '
"<target>/contrib/. Nothing is generated or rewritten; adapting and installing "
"the examples is the administrator's job. Existing files are never overwritten."
),
)
parser.add_argument('--target', default='/opt/netbox', help='NetBox instance root (NETBOX_ROOT).')
args = parser.parse_args(argv)
if not _bundled_data_dir().is_dir():
print(
f'{prog}: this NetBox installation does not include the bundled package data. '
'This command is available only from the installed netbox package (pip/wheel); '
'for an archive or Git installation, follow the standard installation guide instead.',
file=sys.stderr,
)
return 1
if not Path(args.target).is_absolute():
parser.error(f"--target must be an absolute path (got '{args.target}')")
scaffold_instance(args.target)
return 0

View File

@ -18,6 +18,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig
from netbox.registry import registry
from netbox.settings_utils import get_configuration_dir, load_configuration, resolve_install_paths, secret_key_hint
from utilities.release import load_release_data
from utilities.security import validate_peppers
from utilities.string import trailing_slash
@ -30,8 +31,18 @@ from .monkey import get_unique_validators
RELEASE = load_release_data()
VERSION = RELEASE.full_version # Retained for backward compatibility
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Settings package directory (settings.py lives here in both checkout & wheel).
_SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
# All wheel-vs-checkout path branching is centralized in resolve_install_paths(): a wheel
# bundles package data under netbox/_data and keeps mutable instance files under an external
# instance root (NETBOX_ROOT, default /opt/netbox); a checkout keeps both roots as the project
# directory, so archive/git behavior is unchanged.
_PATHS = resolve_install_paths(_SETTINGS_DIR, os.environ)
NETBOX_INSTALL_MODE = _PATHS.install_mode
BASE_DIR = _PATHS.base_dir
# Instance root for wheel installs (holds conf/, media/, reports/, scripts/, static/, units).
NETBOX_ROOT = _PATHS.netbox_root
# Validate the Python version
if sys.version_info < (3, 12): # noqa: UP036
@ -43,17 +54,15 @@ if sys.version_info < (3, 12): # noqa: UP036
# Configuration import
#
# Import the configuration module
config_path = os.getenv('NETBOX_CONFIGURATION', 'netbox.configuration')
try:
configuration = importlib.import_module(config_path)
except ModuleNotFoundError as e:
if getattr(e, 'name') == config_path:
raise ImproperlyConfigured(
f"Specified configuration module ({config_path}) not found. Please define netbox/netbox/configuration.py "
f"per the documentation, or specify an alternate module in the NETBOX_CONFIGURATION environment variable."
)
raise
# Import the configuration module (wheel mode prefers NETBOX_ROOT/conf/configuration.py).
configuration = load_configuration(
install_mode=NETBOX_INSTALL_MODE,
install_root=NETBOX_ROOT,
environ=os.environ,
)
# The directory holding the active configuration.py; ldap_config.py lives beside it.
CONFIGURATION_DIR = get_configuration_dir(configuration)
# Check for missing/conflicting required configuration parameters
for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
@ -119,7 +128,7 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
'users.delete_token': ({'user': '$user'},),
})
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', _PATHS.docs_root)
EMAIL = getattr(configuration, 'EMAIL', {})
STREAMING_EXPORTS = getattr(configuration, 'STREAMING_EXPORTS', False)
EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
@ -150,7 +159,7 @@ LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media')).rstrip('/')
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
@ -175,7 +184,7 @@ REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_
REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
# Required by extras/migrations/0109_script_models.py
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(NETBOX_ROOT, 'reports')).rstrip('/')
RQ = getattr(configuration, 'RQ', {})
if 'WORKER_CLASS' in RQ and RQ['WORKER_CLASS'] != 'utilities.rqworker.NetBoxRQWorker':
warnings.warn(
@ -187,7 +196,7 @@ else:
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(NETBOX_ROOT, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECRET_KEY = getattr(configuration, 'SECRET_KEY') # Required
SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
@ -224,7 +233,7 @@ if type(SECRET_KEY) is not str:
if len(SECRET_KEY) < 50:
raise ImproperlyConfigured(
f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n"
f" python {BASE_DIR}/generate_secret_key.py"
f" {secret_key_hint(NETBOX_INSTALL_MODE, BASE_DIR)}"
)
# Validate API token peppers
@ -592,13 +601,16 @@ USE_X_FORWARDED_HOST = True
X_FRAME_OPTIONS = 'SAMEORIGIN'
# Static files (CSS, JavaScript, Images)
STATIC_ROOT = BASE_DIR + '/static'
# STATIC_ROOT is deliberately not a configuration parameter; static files are collected to <NETBOX_ROOT>/static.
STATIC_ROOT = os.path.join(NETBOX_ROOT, 'static')
STATIC_URL = f'/{BASE_PATH}static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'project-static', 'dist'),
os.path.join(BASE_DIR, 'project-static', 'img'),
os.path.join(BASE_DIR, 'project-static', 'js'),
('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs
# May not exist on a checkout until `manage.py upgrade --build-docs` runs (wheels bundle
# the pre-rendered site); collectstatic tolerates that.
('docs', _PATHS.static_docs_root), # Prefix with /docs
)
# Media URL
@ -874,6 +886,7 @@ LANGUAGES = (
('fr', _('French')),
('it', _('Italian')),
('ja', _('Japanese')),
('ko', _('Korean')),
('lv', _('Latvian')),
('nl', _('Dutch')),
('pl', _('Polish')),

View File

@ -0,0 +1,204 @@
"""Startup helpers for settings.py. Import-safe: no Django settings access at import time."""
import importlib
import importlib.util
import os
import sys
import warnings
from typing import NamedTuple
from django.core.exceptions import ImproperlyConfigured
__all__ = (
'InstallPaths',
'get_configuration_dir',
'load_configuration',
'load_ldap_config',
'resolve_install_paths',
'secret_key_hint',
)
class InstallPaths(NamedTuple):
"""Filesystem layout resolved from the install mode (wheel vs. source checkout)."""
install_mode: str # 'wheel' or 'checkout'
base_dir: str # package data root (BASE_DIR)
netbox_root: str # instance root for mutable files (NETBOX_ROOT)
docs_root: str # documentation sources on a checkout, the pre-rendered site in a wheel (DOCS_ROOT default)
static_docs_root: str # built documentation, source of the STATICFILES 'docs' prefix
def resolve_install_paths(settings_dir, environ):
"""Resolve the install mode and filesystem roots for this NetBox installation.
A wheel bundles package data (including the pre-rendered documentation site)
under netbox/_data and keeps mutable instance files under an external instance root
(NETBOX_ROOT, default /opt/netbox); a source checkout keeps the historical layout,
where both roots are the project directory. All wheel-vs-checkout branching lives
here so settings.py stays declarative.
"""
bundled_data = os.path.join(settings_dir, '_data')
if os.path.isdir(bundled_data):
install_mode = 'wheel'
base_dir = bundled_data
netbox_root = os.path.abspath(environ.get('NETBOX_ROOT', '/opt/netbox'))
docs_root = os.path.join(base_dir, 'docs')
# The wheel bundles the pre-rendered documentation site at _data/docs; it serves as
# both the DOCS_ROOT default and the STATICFILES 'docs' prefix source.
static_docs_root = docs_root
else:
install_mode = 'checkout'
base_dir = os.path.dirname(settings_dir)
netbox_root = base_dir
docs_root = os.path.join(os.path.dirname(base_dir), 'docs')
static_docs_root = os.path.join(base_dir, 'project-static', 'docs')
return InstallPaths(
install_mode=install_mode,
base_dir=base_dir,
netbox_root=netbox_root,
docs_root=docs_root,
static_docs_root=static_docs_root,
)
def secret_key_hint(install_mode, base_dir):
"""Return the command to suggest in the SECRET_KEY-too-short error, based on install mode.
generate_secret_key.py is not packaged in a wheel, so a wheel install points at the
`netbox secret-key` console command instead of the (nonexistent) script path.
"""
if install_mode == 'wheel':
return 'netbox secret-key'
return f'python {base_dir}/generate_secret_key.py'
def _import_module(name):
"""Import a configuration module by dotted path.
Preserve NetBox's historical behavior: a friendly ImproperlyConfigured when the module
itself is absent, but re-raise the original error when the module exists yet imports
something else that is missing.
"""
try:
return importlib.import_module(name)
except ModuleNotFoundError as e:
if e.name == name:
raise ImproperlyConfigured(
f"Specified configuration module ({name}) not found. Please define "
f"netbox/netbox/configuration.py per the documentation, or specify an alternate "
f"module in the NETBOX_CONFIGURATION environment variable."
)
raise
def _import_from_path(module_name, path):
"""Load a configuration module from an explicit file path.
The module is registered in sys.modules (and removed again if execution fails), and the
file's directory is placed on sys.path for the duration of execution so the module can
import siblings, matching normal import semantics closely enough for configuration files.
"""
path = os.path.abspath(path)
module_dir = os.path.dirname(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ImproperlyConfigured(f"Unable to load configuration file {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
sys.path.insert(0, module_dir)
try:
spec.loader.exec_module(module)
except Exception:
if sys.modules.get(module_name) is module:
del sys.modules[module_name]
raise
finally:
# Remove only the entry this helper inserted at index 0.
if sys.path and sys.path[0] == module_dir:
sys.path.pop(0)
return module
def get_configuration_dir(module):
"""Return the directory containing a loaded configuration module (None if unknown)."""
source = getattr(module, '__file__', None)
return os.path.dirname(os.path.abspath(source)) if source else None
def load_configuration(*, install_mode, install_root, environ):
"""Import and return NetBox's configuration module.
An explicit NETBOX_CONFIGURATION module always wins. In wheel mode, prefer
<install_root>/conf/configuration.py, loaded by file path (so a stale source tree at
<install_root>/netbox cannot shadow it and no generic 'configuration' module is left in
sys.modules), then
fall back to the legacy <install_root>/netbox/netbox/configuration.py with a migration
warning. In checkout mode, keep the historical default module.
"""
explicit = environ.get('NETBOX_CONFIGURATION')
if explicit:
return _import_module(explicit)
if install_mode == 'wheel':
conf_dir = os.path.join(install_root, 'conf')
preferred = os.path.join(conf_dir, 'configuration.py')
legacy = os.path.join(install_root, 'netbox', 'netbox', 'configuration.py')
if os.path.isfile(preferred):
if os.path.isfile(legacy):
warnings.warn(
f"Both {preferred} and the legacy {legacy} exist; using {preferred} and "
f"ignoring the legacy file.",
RuntimeWarning,
)
return _import_from_path('netbox_local_configuration', preferred)
if os.path.isfile(legacy):
warnings.warn(
f"Loaded NetBox configuration from the legacy source-tree path {legacy}. For a "
f"pip-installed NetBox, move it to {preferred}.",
RuntimeWarning,
)
return _import_from_path('netbox_legacy_configuration', legacy)
raise ImproperlyConfigured(
f"No NetBox configuration found. For a pip-installed NetBox, create {preferred}, "
f"or set NETBOX_CONFIGURATION to an importable module."
)
return _import_module('netbox.configuration')
def load_ldap_config(config_dir, *, allow_legacy_fallback=False):
"""Load ldap_config.py from the active configuration directory (settings.CONFIGURATION_DIR).
One rule for every install method: the active ldap_config.py is the one next to the
active configuration.py. Checkout installs may additionally allow a legacy fallback to
the historical netbox/netbox/ldap_config.py module, because a custom NETBOX_CONFIGURATION
can live outside the source tree while LDAP config stayed inside it; the fallback warns
so those installs can migrate to the sibling rule.
"""
path = os.path.join(config_dir, 'ldap_config.py') if config_dir else None
if path and os.path.isfile(path):
return _import_from_path('netbox.ldap_config', path)
if allow_legacy_fallback:
try:
module = importlib.import_module('netbox.ldap_config')
except ModuleNotFoundError as e:
if e.name != 'netbox.ldap_config':
raise
else:
warnings.warn(
"Loaded LDAP configuration from the legacy netbox/netbox/ldap_config.py module. "
"Move ldap_config.py into the directory containing the active configuration.py; "
"this fallback may be removed in a future release.",
RuntimeWarning,
)
return module
if not config_dir:
raise ImproperlyConfigured(
"LDAP configuration file not found: unable to determine the directory containing "
"configuration.py."
)
raise ImproperlyConfigured(
"LDAP configuration file not found: Check that ldap_config.py has been created "
"alongside configuration.py. For a pip-installed NetBox, this is "
"NETBOX_ROOT/conf/ldap_config.py."
)

View File

@ -6,8 +6,9 @@ from django.urls import reverse
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from dcim.api.serializers import RackSerializer
from netbox.api.exceptions import QuerySetNotOrdered
from netbox.api.fields import IntegerRangeSerializer
from netbox.api.fields import IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.pagination import NetBoxPagination
from users.models import Token
from utilities.testing import APITestCase
@ -48,6 +49,29 @@ class AppTestCase(APITestCase):
self.assertEqual(response.data['id'], self.user.pk)
class RelatedObjectCountFieldTestCase(TestCase):
"""
RelatedObjectCountFields are populated by annotations applied to a viewset's queryset, which are only
added when serializing an object via its own endpoint (including ?brief=1). They are never annotated when
the object is rendered as a nested related object, so they must be omitted from nested representations to
keep the generated OpenAPI schema honest. See #22154.
"""
def test_count_field_omitted_when_nested(self):
"""A nested serializer must drop RelatedObjectCountFields (e.g. RackSerializer.device_count)."""
serializer = RackSerializer(nested=True)
count_fields = [
name for name, field in serializer.fields.items() if isinstance(field, RelatedObjectCountField)
]
self.assertEqual(count_fields, [])
self.assertNotIn('device_count', serializer.fields)
def test_count_field_retained_in_brief_mode(self):
"""?brief=1 (fields=brief_fields, not nested) must retain RelatedObjectCountFields."""
serializer = RackSerializer(fields=RackSerializer.Meta.brief_fields)
self.assertIn('device_count', serializer.fields)
self.assertIsInstance(serializer.fields['device_count'], RelatedObjectCountField)
class NetBoxPaginationTestCase(TestCase):
def setUp(self):

View File

@ -1,5 +1,7 @@
import datetime
from unittest.mock import MagicMock
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch
from django.conf import settings
from django.contrib.messages.storage.fallback import FallbackStorage
@ -9,8 +11,11 @@ from django.urls import reverse
from rest_framework.test import APIClient
from social_core.exceptions import AuthFailed
from core.models import ObjectType
from core.choices import ManagedFileRootPathChoices
from core.models import ManagedFile, ObjectType
from dcim.models import Rack, Site
from extras.models import ScriptModule
from netbox.authentication import LDAPBackend
from netbox.authentication.misc import _mirror_groups
from netbox.middleware import SocialAuthExceptionMiddleware
from users.constants import TOKEN_PREFIX
@ -560,6 +565,43 @@ class LDAPMirrorGroupsTestCase(TestCase):
)
class LDAPBackendTest(SimpleTestCase):
"""The LDAP backend reads ldap_config.py from the active configuration directory."""
def test_backend_loads_ldap_config_from_configuration_dir(self):
with override_settings(CONFIGURATION_DIR='/srv/netbox/conf', NETBOX_INSTALL_MODE='checkout'):
backend, loader = self._build_backend()
loader.assert_called_once_with('/srv/netbox/conf', allow_legacy_fallback=True)
self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
def test_backend_disables_legacy_fallback_for_wheel_installs(self):
with override_settings(CONFIGURATION_DIR='/opt/netbox/conf', NETBOX_INSTALL_MODE='wheel'):
backend, loader = self._build_backend()
loader.assert_called_once_with('/opt/netbox/conf', allow_legacy_fallback=False)
self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
def _build_backend(self):
fake_ldap = ModuleType('ldap')
fake_ldap.set_option = MagicMock()
backend_module = ModuleType('django_auth_ldap.backend')
backend_module.LDAPSettings = type('LDAPSettings', (), {'_prefix': 'AUTH_LDAP_'})
package = ModuleType('django_auth_ldap')
package.backend = backend_module
ldap_config = ModuleType('netbox.ldap_config')
ldap_config.AUTH_LDAP_SERVER_URI = 'ldaps://example'
with (
patch.dict(sys.modules, {
'ldap': fake_ldap,
'django_auth_ldap': package,
'django_auth_ldap.backend': backend_module,
}),
patch('netbox.authentication.NBLDAPBackend', MagicMock(), create=True),
patch('netbox.authentication.load_ldap_config', return_value=ldap_config) as loader,
):
backend = LDAPBackend()
return backend, loader
class ObjectPermissionAPIViewTestCase(TestCase):
client_class = APIClient
@ -745,6 +787,54 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 204)
class ObjectPermissionProxyModelTestCase(TestCase):
"""
Object-level permission checks against proxy models (e.g. extras.ScriptModule proxying
core.ManagedFile) must evaluate the permission rather than raise ValueError.
"""
@classmethod
def setUpTestData(cls):
cls.managed_file = ManagedFile.objects.create(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='proxy_permission_test.py'
)
cls.script_module = ScriptModule.objects.get(pk=cls.managed_file.pk)
def _grant_scriptmodule_permission(self, constraints=None):
obj_perm = ObjectPermission(name='ScriptModule change', actions=['change'], constraints=constraints)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(ScriptModule, for_concrete_model=False))
def test_has_perm_cross_app_proxy_model(self):
"""An unconstrained proxy-model permission grants access to a proxy instance."""
self._grant_scriptmodule_permission()
self.assertTrue(self.user.has_perm('extras.change_scriptmodule', self.script_module))
def test_has_perm_cross_app_proxy_model_matching_constraints(self):
"""A constrained proxy-model permission grants access when the instance matches."""
self._grant_scriptmodule_permission(constraints={'file_path': 'proxy_permission_test.py'})
self.assertTrue(self.user.has_perm('extras.change_scriptmodule', self.script_module))
def test_has_perm_cross_app_proxy_model_nonmatching_constraints(self):
"""A constrained proxy-model permission denies access when the instance does not match."""
self._grant_scriptmodule_permission(constraints={'file_path': 'other.py'})
self.assertFalse(self.user.has_perm('extras.change_scriptmodule', self.script_module))
def test_has_perm_invalid_permission_object_pair(self):
"""A permission checked against an object of an unrelated model denies instead of raising."""
site = Site.objects.create(name='Proxy Test Site', slug='proxy-test-site')
self._grant_scriptmodule_permission()
self.assertFalse(self.user.has_perm('extras.change_scriptmodule', site))
@override_settings(DEFAULT_PERMISSIONS={'extras.change_nosuchmodel': None})
def test_has_perm_unknown_model_permission(self):
"""A permission naming a nonexistent model denies and logs a warning instead of raising."""
self._grant_scriptmodule_permission()
with self.assertLogs('netbox.auth.ObjectPermissionBackend', level='WARNING'):
self.assertFalse(self.user.has_perm('extras.change_nosuchmodel', self.script_module))
class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
"""
Verify that SSO/SAML authentication failures are surfaced as a login-page message rather than

View File

@ -0,0 +1,131 @@
from contextlib import redirect_stderr, redirect_stdout
from io import StringIO
from unittest.mock import patch
from django.test import SimpleTestCase
from netbox import cli
class CliDispatchTest(SimpleTestCase):
"""The console script resolves pre-configuration commands before importing Django."""
def _main(self, args, argv0='netbox'):
# prog derives from sys.argv[0] even when argv is passed explicitly, so pin both.
stdout, stderr = StringIO(), StringIO()
with (
patch.object(cli.sys, 'argv', [argv0, *args]),
patch('django.core.management.execute_from_command_line') as execute,
redirect_stdout(stdout), redirect_stderr(stderr),
):
rc = cli.main(args)
return rc, execute, stdout.getvalue(), stderr.getvalue()
def test_version_flag_prints_version_without_django(self):
with patch('netbox.cli.version', return_value='4.7.0b1'):
rc, execute, out, _ = self._main(['--version'])
execute.assert_not_called()
self.assertEqual(out, '4.7.0b1\n')
self.assertEqual(rc, 0)
def test_version_command_prints_version_without_django(self):
with patch('netbox.cli.version', return_value='4.7.0b1'):
rc, execute, out, _ = self._main(['version'])
execute.assert_not_called()
self.assertEqual(out, '4.7.0b1\n')
self.assertEqual(rc, 0)
def test_version_help(self):
rc, execute, out, _ = self._main(['version', '--help'])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox version', out)
def test_version_rejects_unexpected_arguments(self):
rc, execute, _, err = self._main(['version', 'bogus'])
execute.assert_not_called()
self.assertEqual(rc, 2)
self.assertIn('unrecognized arguments: bogus', err)
def test_no_arguments_prints_help_without_django(self):
rc, execute, out, _ = self._main([])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox', out)
for command in ('version', 'setup', 'secret-key'):
self.assertIn(command, out)
def test_help_flags_print_help_without_django(self):
for flag in ('-h', '--help'):
with self.subTest(flag=flag):
rc, execute, out, _ = self._main([flag])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox', out)
for command in ('version', 'setup', 'secret-key'):
self.assertIn(command, out)
def test_secret_key_prints_50_char_key_without_django(self):
rc, execute, out, _ = self._main(['secret-key'])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertEqual(len(out.strip()), 50)
def test_secret_key_help(self):
rc, execute, out, _ = self._main(['secret-key', '--help'])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox secret-key', out)
def test_secret_key_rejects_unexpected_arguments(self):
rc, execute, _, err = self._main(['secret-key', 'bogus'])
execute.assert_not_called()
self.assertEqual(rc, 2)
self.assertIn('unrecognized arguments: bogus', err)
def test_setup_dispatches_to_scaffold_with_prog(self):
with patch('netbox.scaffold.main', return_value=0) as setup_main:
rc, execute, _, _ = self._main(['setup', '--target', '/srv/netbox'])
execute.assert_not_called()
setup_main.assert_called_once_with(['--target', '/srv/netbox'], prog='netbox setup')
self.assertEqual(rc, 0)
def test_setup_return_code_propagates(self):
with patch('netbox.scaffold.main', return_value=3):
rc, execute, _, _ = self._main(['setup'])
execute.assert_not_called()
self.assertEqual(rc, 3)
def test_other_commands_dispatch_to_django(self):
with (
patch.object(cli.sys, 'argv', ['/opt/netbox/venv/bin/netbox', 'check', '--deploy']),
patch.dict(cli.os.environ),
patch('django.core.management.execute_from_command_line') as execute,
):
cli.os.environ.pop('DJANGO_SETTINGS_MODULE', None)
rc = cli.main(['check', '--deploy'])
self.assertEqual(cli.os.environ['DJANGO_SETTINGS_MODULE'], 'netbox.settings')
execute.assert_called_once_with(['netbox', 'check', '--deploy'])
self.assertEqual(rc, 0)
def test_subcommand_help_falls_through_to_django(self):
rc, execute, _, _ = self._main(['migrate', '--help'])
execute.assert_called_once_with(['netbox', 'migrate', '--help'])
self.assertEqual(rc, 0)
def test_prog_falls_back_for_python_m_invocation(self):
with patch('netbox.scaffold.main', return_value=0) as setup_main:
self._main(['setup'], argv0='/x/netbox/__main__.py')
setup_main.assert_called_once_with([], prog='netbox setup')
def test_prog_falls_back_when_argv_is_empty(self):
out = StringIO()
with (
patch.object(cli.sys, 'argv', []),
patch('django.core.management.execute_from_command_line') as execute,
redirect_stdout(out),
):
rc = cli.main([])
execute.assert_not_called()
self.assertEqual(rc, 0)
self.assertIn('usage: netbox', out.getvalue())

View File

@ -3,7 +3,9 @@ import re
import strawberry
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from rest_framework import status
from strawberry.extensions import QueryDepthLimiter
@ -333,6 +335,63 @@ class GraphQLAPITestCase(APITestCase):
self.assertNotIn('errors', data)
self.assertEqual(int(data['data']['table_config']['object_type']['id']), site_ct.pk)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_device_list_tags_are_prefetched(self):
"""
Requesting tags on device_list must batch tag lookups (no N+1 per device).
"""
self.add_permissions('dcim.view_device', 'extras.view_tag')
manufacturer = Manufacturer.objects.create(name='Prefetch Manufacturer', slug='prefetch-manufacturer')
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Prefetch Model',
slug='prefetch-model',
)
device_role = DeviceRole.objects.create(name='Prefetch Role', slug='prefetch-role')
site = Site.objects.first()
tag_alpha = Tag.objects.create(name='Prefetch Alpha', slug='prefetch-alpha')
tag_beta = Tag.objects.create(name='Prefetch Beta', slug='prefetch-beta')
devices = Device.objects.bulk_create([
Device(
name=f'Prefetch Device {index}',
device_type=device_type,
role=device_role,
site=site,
)
for index in range(10)
])
for device in devices:
device.tags.set([tag_alpha, tag_beta])
query = """
{
device_list(filters: {role: {slug: {exact: "prefetch-role"}}}) {
name
tags {
slug
}
}
}
"""
url = reverse('graphql')
with CaptureQueriesContext(connection) as context:
response = self.client.post(url, data={'query': query}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['device_list']), 10)
tag_queries = sum(1 for query_record in context.captured_queries if 'extras_tag' in query_record['sql'])
self.assertLessEqual(
tag_queries,
2,
msg=f'Expected batched tag prefetch, got {tag_queries} tag queries for 10 devices',
)
def test_offset_pagination(self):
self.add_permissions('dcim.view_site')
url = reverse('graphql')

View File

@ -0,0 +1,150 @@
import contextlib
import tempfile
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from django.test import SimpleTestCase
from netbox import scaffold
_CONFIG_TEMPLATE_TEXT = (
"# example configuration\n"
"STORAGE_ROOT = '/opt/netbox/netbox/media'\n"
"NETBOX_ROOT = '/opt/netbox'\n"
)
_CONTRIB_FILENAMES = (
'apache.conf', 'gunicorn.py', 'netbox-rq.service', 'netbox.env', 'netbox.service', 'nginx.conf', 'uwsgi.ini',
)
class ScaffoldInstanceTest(SimpleTestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
root = Path(tmp.name)
bundled = root / '_data'
contrib_src = bundled / 'contrib'
contrib_src.mkdir(parents=True)
for name in _CONTRIB_FILENAMES:
(contrib_src / name).write_text(f'# {name} contents\n')
# A real install leaves __pycache__ here too: pip's post-install bytecode compilation
# runs on gunicorn.py, the only .py file among the examples.
(contrib_src / '__pycache__').mkdir()
self.contrib_src = contrib_src
template = root / 'configuration_example.py'
template.write_text(_CONFIG_TEMPLATE_TEXT)
self.target = root / 'opt'
self.target.mkdir()
self.enterContext(patch('netbox.scaffold._bundled_data_dir', return_value=bundled))
self.enterContext(patch('netbox.scaffold._config_template', return_value=template))
self.enterContext(patch('netbox.scaffold._contrib_dir', return_value=contrib_src))
def test_scaffolds_configuration_and_contrib_examples(self):
"""A fresh target gets conf/__init__.py, conf/configuration.py, local_requirements.txt, and contrib/."""
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'conf' / '__init__.py').read_bytes(), b'')
self.assertEqual(
(self.target / 'conf' / 'configuration.py').read_bytes(),
_CONFIG_TEMPLATE_TEXT.encode('utf-8'),
)
self.assertEqual((self.target / 'local_requirements.txt').read_bytes(), b'')
for name in _CONTRIB_FILENAMES:
self.assertEqual(
(self.target / 'contrib' / name).read_bytes(),
(self.contrib_src / name).read_bytes(),
)
expected = [
str(self.target / 'conf' / '__init__.py'),
str(self.target / 'conf' / 'configuration.py'),
str(self.target / 'local_requirements.txt'),
*(str(self.target / 'contrib' / name) for name in _CONTRIB_FILENAMES),
]
self.assertEqual(written, expected)
self.assertFalse((self.target / 'contrib' / '__pycache__').exists())
def test_never_clobbers_existing_configuration(self):
"""An existing conf/configuration.py is left untouched."""
(self.target / 'conf').mkdir(parents=True)
(self.target / 'conf' / 'configuration.py').write_text('SECRET = 1')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
self.assertNotIn(str(self.target / 'conf' / 'configuration.py'), written)
def test_never_clobbers_existing_conf_init(self):
"""An existing conf/__init__.py is left untouched."""
(self.target / 'conf').mkdir(parents=True)
(self.target / 'conf' / '__init__.py').write_text('# user-owned\n')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'conf' / '__init__.py').read_text(), '# user-owned\n')
self.assertNotIn(str(self.target / 'conf' / '__init__.py'), written)
def test_never_clobbers_existing_local_requirements(self):
"""An existing local_requirements.txt is left untouched."""
(self.target / 'local_requirements.txt').write_text('django-auth-ldap\n')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
self.assertNotIn(str(self.target / 'local_requirements.txt'), written)
def test_never_clobbers_existing_contrib_file(self):
"""An existing <target>/contrib/<name> file is left untouched; siblings are still written."""
(self.target / 'contrib').mkdir(parents=True)
(self.target / 'contrib' / 'gunicorn.py').write_text('# edited\n')
written = scaffold.scaffold_instance(self.target)
self.assertEqual((self.target / 'contrib' / 'gunicorn.py').read_text(), '# edited\n')
self.assertNotIn(str(self.target / 'contrib' / 'gunicorn.py'), written)
self.assertIn(str(self.target / 'contrib' / 'nginx.conf'), written)
def test_main_uses_arguments(self):
"""main() dispatches --target through to scaffold_instance."""
result = scaffold.main(['--target', str(self.target)])
self.assertEqual(result, 0)
self.assertEqual(
(self.target / 'conf' / 'configuration.py').read_bytes(),
_CONFIG_TEMPLATE_TEXT.encode('utf-8'),
)
def test_main_rejects_relative_target(self):
"""A relative --target is rejected with rc 2."""
with (
contextlib.redirect_stderr(StringIO()),
self.assertRaises(SystemExit) as cm,
):
scaffold.main(['--target', 'relative/path'])
self.assertEqual(cm.exception.code, 2)
def test_main_help_uses_netbox_setup_prog(self):
"""--help shows the netbox setup prog name."""
out = StringIO()
with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as cm:
scaffold.main(['--help'])
self.assertEqual(cm.exception.code, 0)
self.assertIn('usage: netbox setup', out.getvalue())
def test_main_fails_friendly_without_bundled_data(self):
"""Without bundled package data, main() reports rc 1 and a friendly stderr message."""
err = StringIO()
with (
patch('netbox.scaffold._bundled_data_dir', return_value=self.target / 'missing'),
contextlib.redirect_stderr(err),
):
rc = scaffold.main(['--target', '/tmp/x'])
self.assertEqual(rc, 1)
self.assertIn('installed netbox package', err.getvalue())
class ScaffoldResourceHelpersTest(SimpleTestCase):
"""The unpatched resource helpers resolve real paths under the installed netbox package."""
def test_bundled_data_dir_resolves_under_package(self):
self.assertTrue(str(scaffold._bundled_data_dir()).endswith('_data'))
def test_config_template_resolves_under_package(self):
self.assertTrue(str(scaffold._config_template()).endswith('configuration_example.py'))
def test_contrib_dir_resolves_under_bundled_data(self):
self.assertTrue(str(scaffold._contrib_dir()).endswith('_data/contrib'))

View File

@ -0,0 +1,281 @@
import os
import sys
import tempfile
from types import ModuleType
from unittest.mock import patch
from django.conf import settings as django_settings
from django.core.exceptions import ImproperlyConfigured
from django.test import SimpleTestCase
from netbox import settings_utils
class LoadConfigurationTest(SimpleTestCase):
def test_explicit_module_wins(self):
with patch('netbox.settings_utils.importlib.import_module') as import_module:
settings_utils.load_configuration(
install_mode='wheel', install_root='/opt/netbox',
environ={'NETBOX_CONFIGURATION': 'my.config'},
)
import_module.assert_called_once_with('my.config')
def test_checkout_uses_default_module(self):
with patch('netbox.settings_utils.importlib.import_module') as import_module:
settings_utils.load_configuration(
install_mode='checkout', install_root='/repo', environ={},
)
import_module.assert_called_once_with('netbox.configuration')
def test_checkout_missing_module_raises_improperly_configured(self):
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'netbox.configuration'", name='netbox.configuration'),
):
with self.assertRaises(ImproperlyConfigured):
settings_utils.load_configuration(
install_mode='checkout', install_root='/repo', environ={},
)
def test_wheel_prefers_conf_dir(self):
with tempfile.TemporaryDirectory() as root:
conf = os.path.join(root, 'conf')
os.mkdir(conf)
preferred = os.path.join(conf, 'configuration.py')
open(preferred, 'w').close()
saved = list(sys.path)
try:
with patch('netbox.settings_utils._import_from_path') as import_from_path:
settings_utils.load_configuration(
install_mode='wheel', install_root=root, environ={},
)
import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
self.assertEqual(sys.path, saved)
finally:
sys.path[:] = saved
def test_wheel_falls_back_to_legacy_with_warning(self):
with tempfile.TemporaryDirectory() as root:
legacy_dir = os.path.join(root, 'netbox', 'netbox')
os.makedirs(legacy_dir)
legacy = os.path.join(legacy_dir, 'configuration.py')
open(legacy, 'w').close()
with (
patch('netbox.settings_utils._import_from_path') as importer,
self.assertWarns(RuntimeWarning),
):
settings_utils.load_configuration(
install_mode='wheel', install_root=root, environ={},
)
self.assertEqual(importer.call_args.args[1], legacy)
def test_wheel_missing_configuration_raises(self):
with tempfile.TemporaryDirectory() as root:
with self.assertRaisesMessage(ImproperlyConfigured, 'conf/configuration.py'):
settings_utils.load_configuration(
install_mode='wheel', install_root=root, environ={},
)
def test_explicit_module_reraises_other_import_error(self):
# A missing dependency of the config module must propagate, not become a friendly error.
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
):
with self.assertRaises(ModuleNotFoundError):
settings_utils.load_configuration(
install_mode='checkout', install_root='/repo',
environ={'NETBOX_CONFIGURATION': 'my.config'},
)
def test_import_from_path_loads_module_and_restores_sys_path(self):
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'legacy_cfg.py')
with open(path, 'w') as handle:
handle.write('ALLOWED_HOSTS = ["example"]\n')
self.addCleanup(sys.modules.pop, 'netbox_test_legacy_cfg', None)
saved = list(sys.path)
module = settings_utils._import_from_path('netbox_test_legacy_cfg', path)
self.assertEqual(module.ALLOWED_HOSTS, ['example'])
self.assertEqual(sys.path, saved)
self.assertIs(sys.modules['netbox_test_legacy_cfg'], module)
def test_import_from_path_removes_module_on_failure(self):
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'broken_cfg.py')
with open(path, 'w') as handle:
handle.write('raise RuntimeError("Simulated configuration error")\n')
with self.assertRaisesMessage(RuntimeError, 'Simulated configuration error'):
settings_utils._import_from_path('netbox_test_broken_cfg', path)
self.assertNotIn('netbox_test_broken_cfg', sys.modules)
def test_import_from_path_rejects_unloadable_path(self):
# A suffix-less file yields no loader; the helper must fail cleanly.
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'noext')
open(path, 'w').close()
with self.assertRaisesMessage(ImproperlyConfigured, 'Unable to load'):
settings_utils._import_from_path('netbox_test_noext_cfg', path)
def test_import_from_path_preserves_preexisting_sys_path_entry(self):
# Only the index-0 entry this helper inserted is popped; a pre-existing entry survives.
with tempfile.TemporaryDirectory() as root:
path = os.path.join(root, 'preexisting_cfg.py')
with open(path, 'w') as handle:
handle.write('ALLOWED_HOSTS = ["example"]\n')
self.addCleanup(sys.modules.pop, 'netbox_test_preexisting_cfg', None)
saved = list(sys.path)
sys.path.append(root)
try:
settings_utils._import_from_path('netbox_test_preexisting_cfg', path)
self.assertEqual(sys.path, saved + [root])
finally:
sys.path[:] = saved
def test_wheel_both_configs_present_warns_and_prefers_conf(self):
with tempfile.TemporaryDirectory() as root:
conf = os.path.join(root, 'conf')
os.mkdir(conf)
preferred = os.path.join(conf, 'configuration.py')
open(preferred, 'w').close()
legacy_dir = os.path.join(root, 'netbox', 'netbox')
os.makedirs(legacy_dir)
open(os.path.join(legacy_dir, 'configuration.py'), 'w').close()
saved = list(sys.path)
try:
with (
patch('netbox.settings_utils._import_from_path') as import_from_path,
self.assertWarns(RuntimeWarning),
):
settings_utils.load_configuration(install_mode='wheel', install_root=root, environ={})
import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
finally:
sys.path[:] = saved
class ConfigurationDirTest(SimpleTestCase):
def test_returns_directory_of_module_file(self):
module = ModuleType('cfg')
module.__file__ = '/srv/netbox/conf/configuration.py'
self.assertEqual(settings_utils.get_configuration_dir(module), '/srv/netbox/conf')
def test_returns_none_without_file(self):
self.assertIsNone(settings_utils.get_configuration_dir(ModuleType('cfg')))
class ResolveInstallPathsTest(SimpleTestCase):
"""resolve_install_paths() centralizes wheel-vs-checkout filesystem layout decisions."""
def test_checkout_roots(self):
with tempfile.TemporaryDirectory() as root:
settings_dir = os.path.join(root, 'netbox', 'netbox')
os.makedirs(settings_dir)
base_dir = os.path.join(root, 'netbox')
paths = settings_utils.resolve_install_paths(settings_dir, {})
self.assertEqual(paths.install_mode, 'checkout')
self.assertEqual(paths.base_dir, base_dir)
self.assertEqual(paths.netbox_root, base_dir)
self.assertEqual(paths.docs_root, os.path.join(root, 'docs'))
self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'project-static', 'docs'))
def test_wheel_roots_default_netbox_root(self):
with tempfile.TemporaryDirectory() as root:
settings_dir = os.path.join(root, 'site-packages', 'netbox')
base_dir = os.path.join(settings_dir, '_data')
os.makedirs(base_dir)
paths = settings_utils.resolve_install_paths(settings_dir, {})
self.assertEqual(paths.install_mode, 'wheel')
self.assertEqual(paths.base_dir, base_dir)
self.assertEqual(paths.netbox_root, '/opt/netbox')
self.assertEqual(paths.docs_root, os.path.join(base_dir, 'docs'))
self.assertEqual(paths.static_docs_root, os.path.join(base_dir, 'docs'))
def test_netbox_root_env_override_is_abspathed(self):
with tempfile.TemporaryDirectory() as root:
settings_dir = os.path.join(root, 'site-packages', 'netbox')
os.makedirs(os.path.join(settings_dir, '_data'))
paths = settings_utils.resolve_install_paths(settings_dir, {'NETBOX_ROOT': 'relative/root'})
self.assertEqual(paths.netbox_root, os.path.abspath('relative/root'))
class SecretKeyHintTest(SimpleTestCase):
"""secret_key_hint() picks the SECRET_KEY-too-short hint by install mode."""
def test_wheel_mode_suggests_console_command(self):
self.assertEqual(settings_utils.secret_key_hint('wheel', '/opt/netbox/lib/netbox'), 'netbox secret-key')
def test_checkout_mode_suggests_generate_secret_key_script(self):
self.assertEqual(
settings_utils.secret_key_hint('checkout', '/repo/netbox'),
'python /repo/netbox/generate_secret_key.py',
)
class LoadLdapConfigTest(SimpleTestCase):
def test_loads_sibling_ldap_config(self):
with tempfile.TemporaryDirectory() as conf_dir:
with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
handle.write('AUTH_LDAP_SERVER_URI = "ldaps://example"\n')
self.addCleanup(sys.modules.pop, 'netbox.ldap_config', None)
module = settings_utils.load_ldap_config(conf_dir)
self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://example')
self.assertIs(sys.modules['netbox.ldap_config'], module)
def test_legacy_fallback_loads_historical_module_with_warning(self):
legacy = ModuleType('netbox.ldap_config')
legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
with tempfile.TemporaryDirectory() as conf_dir:
with patch.dict(sys.modules, {'netbox.ldap_config': legacy}), self.assertWarns(RuntimeWarning):
module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
self.assertIs(module, legacy)
def test_legacy_fallback_prefers_sibling_file(self):
legacy = ModuleType('netbox.ldap_config')
legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
with tempfile.TemporaryDirectory() as conf_dir:
with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
handle.write('AUTH_LDAP_SERVER_URI = "ldaps://sibling"\n')
with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://sibling')
def test_legacy_fallback_disabled_raises(self):
legacy = ModuleType('netbox.ldap_config')
with tempfile.TemporaryDirectory() as conf_dir:
with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
settings_utils.load_ldap_config(conf_dir)
def test_legacy_fallback_missing_module_raises(self):
with tempfile.TemporaryDirectory() as conf_dir:
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'netbox.ldap_config'", name='netbox.ldap_config'),
):
with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
def test_legacy_fallback_reraises_broken_dependency(self):
with tempfile.TemporaryDirectory() as conf_dir:
with patch(
'netbox.settings_utils.importlib.import_module',
side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
):
with self.assertRaises(ModuleNotFoundError):
settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
def test_none_config_dir_raises(self):
with self.assertRaisesMessage(ImproperlyConfigured, 'unable to determine'):
settings_utils.load_ldap_config(None)
def test_missing_file_raises(self):
with tempfile.TemporaryDirectory() as conf_dir:
with self.assertRaisesMessage(ImproperlyConfigured, 'ldap_config.py'):
settings_utils.load_ldap_config(conf_dir)
def test_configuration_dir_setting_matches_active_configuration(self):
from netbox import configuration_testing
self.assertEqual(
django_settings.CONFIGURATION_DIR,
os.path.dirname(os.path.abspath(configuration_testing.__file__)),
)

View File

@ -519,13 +519,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
def _save_object(self, model_form, request, parent_idx):
_action = 'Updated' if model_form.instance.pk else 'Created'
# Save the primary object
# Save the primary object. Object-level permissions are enforced in aggregate by
# create_and_update_objects() once all records have been processed.
obj = self.save_object(model_form, request)
# Enforce object-level permissions
if not self.queryset.filter(pk=obj.pk).first():
raise PermissionsViolation()
# Iterate through the related object forms (if any), validating and saving each instance.
for field_name, related_object_form in self.related_object_forms.items():
@ -670,9 +667,28 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
)
raise ValidationError(error_msg)
# A record which references an existing object by ID performs an update rather than a creation. The bulk
# import view is gated only on the 'add' permission, but updating an existing object requires 'change' (as
# enforced by the REST API). Require the 'change' permission at the model level before permitting any updates,
# and restrict the prefetched objects to those the user is permitted to change (object-level enforcement).
update_pks = set(prefetch_ids)
if prefetch_ids:
change_permission = get_permission_for_model(self.queryset.model, 'change')
if not request.user.has_perm(change_permission):
raise ValidationError(
_(
"This import includes {count} record(s) that reference an existing object by ID and would "
"update it, which requires the {permission} permission. Remove the ID column to create new "
"objects instead."
).format(count=len(prefetch_ids), permission=change_permission)
)
change_queryset = self.queryset.model.objects.restrict(request.user, 'change')
else:
change_queryset = self.queryset.model.objects
prefetched_objects = {
obj.pk: obj
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
for obj in change_queryset.filter(id__in=prefetch_ids)
} if prefetch_ids else {}
# Delay tree updates until all saves are complete (MPTT plugin models only;
@ -680,6 +696,17 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
with _delay_mptt_updates(self.queryset.model):
saved_objects = self._process_import_records(form, request, records, prefetched_objects)
# Enforce object-level permissions in aggregate. Newly created objects are constrained by the 'add'
# permission (self.queryset is already restricted to 'add'); updated objects by 'change' (reusing the
# queryset built above, so no additional per-record work). This runs inside the caller's atomic
# transaction, so any violation rolls back the entire import.
created_pks = [obj.pk for obj in saved_objects if obj.pk not in update_pks]
if self.queryset.filter(pk__in=created_pks).count() != len(created_pks):
raise PermissionsViolation()
updated_pks = [obj.pk for obj in saved_objects if obj.pk in update_pks]
if updated_pks and change_queryset.filter(pk__in=updated_pks).count() != len(updated_pks):
raise PermissionsViolation()
return saved_objects
#
@ -721,14 +748,11 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
return redirect(redirect_url)
try:
# Iterate through data and bind each record to a new model form instance.
# Iterate through data and bind each record to a new model form instance. Object-level
# permissions are enforced within create_and_update_objects().
with transaction.atomic(using=router.db_for_write(model)):
new_objects = self.create_and_update_objects(form, request)
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects):
raise PermissionsViolation
msg = _('Imported {count} {object_type}').format(
count=len(new_objects),
object_type=model._meta.verbose_name_plural

View File

@ -603,10 +603,16 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
))
# Redirect user on success
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
if '_addanother' in request.POST:
redirect_url = request.path
params = prepare_cloned_fields(new_objs[-1])
if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')
if params:
redirect_url += f"?{params.urlencode()}"
if safe_for_redirect(redirect_url):
return redirect(redirect_url)
return redirect(self.get_return_url(request))
except (AbortRequest, PermissionsViolation) as e:
logger.debug(e.message)
form.add_error(None, e.message)

Some files were not shown because too many files have changed in this diff Show More