diff --git a/CHANGELOG.md b/CHANGELOG.md index d38be1f..d35f632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/docs/guides/audit-log.md b/docs/guides/audit-log.md index 97a38b4..4371287 100644 --- a/docs/guides/audit-log.md +++ b/docs/guides/audit-log.md @@ -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 @@ -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"} diff --git a/docs/guides/branching.md b/docs/guides/branching.md index 5052dc0..dd68145 100644 --- a/docs/guides/branching.md +++ b/docs/guides/branching.md @@ -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. diff --git a/nsc/cli/globals.py b/nsc/cli/globals.py index f980636..61457e2 100644 --- a/nsc/cli/globals.py +++ b/nsc/cli/globals.py @@ -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 @@ -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 diff --git a/nsc/http/audit.py b/nsc/http/audit.py index 9c45d73..bf267f8 100644 --- a/nsc/http/audit.py +++ b/nsc/http/audit.py @@ -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 @@ -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]: @@ -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, @@ -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: diff --git a/nsc/http/client.py b/nsc/http/client.py index 196fab7..6063f63 100644 --- a/nsc/http/client.py +++ b/nsc/http/client.py @@ -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 @@ -45,6 +45,11 @@ class _ProfileLike(Protocol): timeout: float +class ResolvedBranch(NamedTuple): + name: str + schema_id: str + + class NetBoxClient: def __init__( self, @@ -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. @@ -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={ @@ -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: @@ -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 diff --git a/tests/cli/test_branch_commands.py b/tests/cli/test_branch_commands.py index eb2ecb2..836a272 100644 --- a/tests/cli/test_branch_commands.py +++ b/tests/cli/test_branch_commands.py @@ -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" diff --git a/tests/http/test_audit.py b/tests/http/test_audit.py index 321767d..a9f80f8 100644 --- a/tests/http/test_audit.py +++ b/tests/http/test_audit.py @@ -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 diff --git a/tests/http/test_client_branch.py b/tests/http/test_client_branch.py index 7c1de84..7b95cae 100644 --- a/tests/http/test_client_branch.py +++ b/tests/http/test_client_branch.py @@ -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/" @@ -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