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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to netbox-super-cli are tracked here. Format follows [Keep a

## Unreleased

### Added

- **Audit log entries record the branch name** ([#172]). A request made in a
branch now carries a top-level `branch` key with the branch's name next to the
`X-Netbox-Branch` schema_id header, including under `audit_redaction: full`.
Lines for requests on main are unchanged (no `branch` key).

## v1.9.0 — 2026-10-25

Minor release. Plugin endpoints get correct per-resource commands (**plugin command paths change**). Branch conflicts render as a table, and server data can no longer drive your terminal.
Expand Down Expand Up @@ -239,6 +246,7 @@ notices, and a fix for `nsc login` reporting the wrong user.
[#173]: https://github.com/thomaschristory/netbox-super-cli/issues/173
[#150]: https://github.com/thomaschristory/netbox-super-cli/issues/150
[#151]: https://github.com/thomaschristory/netbox-super-cli/issues/151
[#172]: https://github.com/thomaschristory/netbox-super-cli/issues/172

## v1.7.0 — 2026-09-10

Expand Down
11 changes: 10 additions & 1 deletion docs/guides/audit-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ JSON object per line), including dry-runs (flagged so you can tell they were
never sent). The file is created owner-only (`0600`) inside a `0700` directory,
so a co-located user on a shared host cannot read your mutations.

## Branch name

A request made in a [NetBox Branching](branching.md) branch carries a top-level
`branch` key with the branch's name, next to the `X-Netbox-Branch` request
header (the branch's schema_id). The key is set once `nsc` has resolved the
branch, so the lookups it makes to resolve it are logged without it. Requests
on main have no `branch` key at all, so their lines are unchanged.

## Redaction modes

The `audit_redaction` setting under `defaults` in `~/.nsc/config.yaml` chooses
Expand Down Expand Up @@ -40,7 +48,8 @@ what NetBox returned, but sensitive values are masked **before write**:
For compliance-sensitive deployments. Every body is **omitted entirely** (not
truncated), regardless of shape or size — small JSON, large JSON, error
envelopes, and multipart payloads all collapse to nothing. Each audit line
contains exactly five keys:
contains exactly five keys, plus `branch` when the request was made in a
[branch](branching.md):

```json
{"method":"POST","url":"https://nb/api/users/users/","status_code":201,"timestamp":"2026-06-25T12:00:00.000Z","profile":"prod"}
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/branching.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ prints the active branch and where it came from (`flag`, `env` or `profile`),
or `main`.

Every request made in a branch is recorded in the [audit log](audit-log.md)
with its `X-Netbox-Branch` header (the branch's schema_id). When a command
with its `X-Netbox-Branch` header (the branch's schema_id) and a top-level
`branch` key holding the branch's name, even if you selected it by schema_id.
When a command
fails while a branch is active, its error envelope names the branch in
`details.branch`, whatever the failure is.

Expand Down
9 changes: 5 additions & 4 deletions nsc/cli/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from nsc.config import default_paths
from nsc.config.models import Config, ObjectColorMode
from nsc.http.client import NetBoxClient
from nsc.http.client import NetBoxClient, ResolvedBranch
from nsc.output.render import select_format
from nsc.schema.source import resolve_command_model

Expand Down Expand Up @@ -82,9 +82,10 @@ def build_runtime_context(state: GlobalState) -> RuntimeContext:
)


def _branch_resolver(ref: str) -> Callable[[NetBoxClient], str]:
def resolve(client: NetBoxClient) -> str:
return require_ready(get_branch(client, ref)).schema_id
def _branch_resolver(ref: str) -> Callable[[NetBoxClient], ResolvedBranch]:
def resolve(client: NetBoxClient) -> ResolvedBranch:
branch = require_ready(get_branch(client, ref))
return ResolvedBranch(name=branch.name, schema_id=branch.schema_id)

return resolve

Expand Down
28 changes: 20 additions & 8 deletions nsc/http/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class AuditEntry(_Frozen):
applied: bool = False
explain: bool = False
profile: str | None = None
branch: str | None = None
redaction: AuditRedaction = AuditRedaction.SAFE


Expand Down Expand Up @@ -156,13 +157,23 @@ def _to_dict_full(entry: AuditEntry) -> dict[str, Any]:
so neither query params nor `user:pass@` userinfo leak. Only `status_code`
is taken from the response object.
"""
return {
"method": entry.method.value,
"url": _sanitize_url(entry.url),
"status_code": entry.response_status_code,
"timestamp": entry.timestamp,
"profile": entry.profile,
}
return _with_branch(
{
"method": entry.method.value,
"url": _sanitize_url(entry.url),
"status_code": entry.response_status_code,
"timestamp": entry.timestamp,
"profile": entry.profile,
},
entry,
)


def _with_branch(line: dict[str, Any], entry: AuditEntry) -> dict[str, Any]:
# Omitted (not null) off-branch so unbranched lines keep their pre-#172 shape.
if entry.branch is not None:
line["branch"] = entry.branch
return line


def _to_dict(entry: AuditEntry) -> dict[str, Any]:
Expand All @@ -171,7 +182,7 @@ def _to_dict(entry: AuditEntry) -> dict[str, Any]:
return _to_dict_full(entry)
req_body, req_trunc = truncate_body(redact_body(entry.request_body, entry.sensitive_paths))
resp_body, resp_trunc = truncate_body(entry.response_body)
return {
line: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"timestamp": entry.timestamp,
"operation_id": entry.operation_id,
Expand Down Expand Up @@ -203,6 +214,7 @@ def _to_dict(entry: AuditEntry) -> dict[str, Any]:
"applied": entry.applied,
"explain": entry.explain,
}
return _with_branch(line, entry)


def _warn(msg: str) -> None:
Expand Down
19 changes: 15 additions & 4 deletions nsc/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import Callable
from datetime import UTC, datetime
from types import TracebackType
from typing import TYPE_CHECKING, Any, Protocol, cast
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, cast
from urllib.parse import urlsplit

from nsc.config.models import AuditRedaction
Expand Down Expand Up @@ -45,6 +45,11 @@ class _ProfileLike(Protocol):
timeout: float


class ResolvedBranch(NamedTuple):
name: str
schema_id: str


class NetBoxClient:
def __init__(
self,
Expand All @@ -53,7 +58,7 @@ def __init__(
debug: bool = False,
redaction: AuditRedaction = AuditRedaction.SAFE,
profile_name: str | None = None,
branch_resolver: Callable[[NetBoxClient], str] | None = None,
branch_resolver: Callable[[NetBoxClient], str | ResolvedBranch] | None = None,
) -> None:
import httpx2 # noqa: PLC0415 # deferred: keeps httpx2 off the CLI-startup path.

Expand All @@ -68,6 +73,7 @@ def __init__(
self._branch_error: BaseException | None = None
self._branch_lock = threading.Lock()
self._branch_resolving_thread: int | None = None
self._branch_name: str | None = None
self._client = httpx2.Client(
base_url=self._url,
headers={
Expand Down Expand Up @@ -275,13 +281,17 @@ def ensure_branch(self) -> None:
return
self._branch_resolving_thread = threading.get_ident()
try:
schema_id = resolver(self)
resolved = resolver(self)
except BaseException as exc:
self._branch_error = exc
raise
finally:
self._branch_resolving_thread = None
self._client.headers[BRANCH_HEADER] = schema_id
if isinstance(resolved, ResolvedBranch):
self._branch_name = resolved.name
self._client.headers[BRANCH_HEADER] = resolved.schema_id
else:
self._client.headers[BRANCH_HEADER] = resolved
self._branch_pending = False

def _absolute(self, path: str, params: dict[str, Any] | None) -> str:
Expand Down Expand Up @@ -363,6 +373,7 @@ def _record_attempt(
applied=is_write,
explain=False,
profile=self._profile_name,
branch=self._branch_name,
redaction=self._redaction,
)
log_dir = default_paths().logs_dir
Expand Down
30 changes: 30 additions & 0 deletions tests/cli/test_branch_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,33 @@ def test_ignored_profile_branch_prints_notice(httpx2_mock: respx.Router, _home:
assert result.exit_code == 0, result.output
assert "keep-me" in result.stderr
assert "ignored" in result.stderr


@pytest.mark.httpx2(assert_all_called=False)
def test_branched_write_audit_records_branch_name(httpx2_mock: respx.Router, _home: Path) -> None:
# Selected by schema_id: the audit must still carry the resolved human name.
_mock_schema(httpx2_mock)
data = branch_json()
httpx2_mock.get(BRANCHES, params={"name": "abcd1234"}).respond(200, json=page())
httpx2_mock.get(BRANCHES, params={"schema_id": "abcd1234"}).respond(200, json=page(data))
httpx2_mock.post(f"{NB}/api/dcim/sites/").respond(201, json={"id": 1, "name": "x"})
result = _invoke(
"--branch",
"abcd1234",
"dcim",
"sites",
"create",
"--field",
"name=x",
"--field",
"slug=x",
"--apply",
"--output",
"json",
)
assert result.exit_code == 0, result.output
lines = (_home / "logs" / "audit.jsonl").read_text().splitlines()
entry = json.loads(lines[-1])
assert entry["method"] == "POST"
assert entry["branch"] == "my-change"
assert entry["request"]["headers"]["X-Netbox-Branch"] == "abcd1234"
23 changes: 23 additions & 0 deletions tests/http/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,26 @@ def test_write_failure_emits_stderr_warning(
target.parent.chmod(0o700)
err = capsys.readouterr().err
assert "audit log" in err.lower() or "could not write" in err.lower()


def test_branch_name_serialized_when_set(tmp_path: Path) -> None:
path = tmp_path / "audit.jsonl"
append_audit_jsonl(_entry(branch="my-change"), path=path)
assert json.loads(path.read_text())["branch"] == "my-change"


def test_branch_key_omitted_when_unset(tmp_path: Path) -> None:
path = tmp_path / "audit.jsonl"
append_audit_jsonl(_entry(), path=path)
assert "branch" not in json.loads(path.read_text())


def test_full_redaction_keeps_branch_name(tmp_path: Path) -> None:
from nsc.config.models import AuditRedaction # noqa: PLC0415

path = tmp_path / "audit.jsonl"
append_audit_jsonl(_entry(branch="my-change", redaction=AuditRedaction.FULL), path=path)
append_audit_jsonl(_entry(redaction=AuditRedaction.FULL), path=path)
branched, plain = (json.loads(x) for x in path.read_text().splitlines())
assert branched["branch"] == "my-change"
assert "branch" not in plain
49 changes: 48 additions & 1 deletion tests/http/test_client_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import respx

from nsc.branching.errors import BranchNotReadyError
from nsc.http.client import NetBoxClient
from nsc.http.client import NetBoxClient, ResolvedBranch

SITES = "https://nb.example/api/dcim/sites/"
LOOKUP = "https://nb.example/api/plugins/branching/branches/"
Expand Down Expand Up @@ -110,3 +110,50 @@ def slow(client: NetBoxClient) -> str:
second.join(5)
assert sites.call_count == 2
assert all(c.request.headers["X-NetBox-Branch"] == "abcd1234" for c in sites.calls)


def test_audit_entry_records_resolved_branch_name(
httpx2_mock: respx.Router, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("NSC_HOME", str(tmp_path))
httpx2_mock.post(SITES).respond(201, json={"id": 1})

def resolver(client: NetBoxClient) -> ResolvedBranch:
return ResolvedBranch(name="my-change", schema_id="abcd1234")

with NetBoxClient(_FakeProfile(), branch_resolver=resolver) as client:
client.post("/api/dcim/sites/", json={"name": "x"})
line = json.loads((tmp_path / "logs" / "audit.jsonl").read_text().splitlines()[-1])
assert line["branch"] == "my-change"
assert line["request"]["headers"]["X-Netbox-Branch"] == "abcd1234"


def test_resolver_lookups_are_audited_without_branch(
httpx2_mock: respx.Router, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("NSC_HOME", str(tmp_path))
httpx2_mock.get(LOOKUP).respond(200, json={"results": []})
httpx2_mock.get(SITES).respond(200, json={"results": []})
seen: list[dict[str, object]] = []

def resolver(client: NetBoxClient) -> ResolvedBranch:
client.get("/api/plugins/branching/branches/", {"name": "my-change"})
seen.append(json.loads((tmp_path / "logs" / "last-request.json").read_text()))
return ResolvedBranch(name="my-change", schema_id="abcd1234")

with NetBoxClient(_FakeProfile(), branch_resolver=resolver) as client:
client.get("/api/dcim/sites/")
assert "branch" not in seen[0]
last = json.loads((tmp_path / "logs" / "last-request.json").read_text())
assert last["branch"] == "my-change"


def test_no_branch_key_without_resolver(
httpx2_mock: respx.Router, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("NSC_HOME", str(tmp_path))
httpx2_mock.post(SITES).respond(201, json={"id": 1})
with NetBoxClient(_FakeProfile()) as client:
client.post("/api/dcim/sites/", json={"name": "x"})
line = json.loads((tmp_path / "logs" / "audit.jsonl").read_text().splitlines()[-1])
assert "branch" not in line
Loading