diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e281e5c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# The build context is the crate source only. `target/` is many GB of local +# build artifacts that the image builds from scratch anyway, and shipping a +# local config would bake a NetBox token into the image. +target/ +.git/ +*.toml.local +nbox.toml +config.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4076028..24999d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 move off yanked `chacha20` 0.10.0 and `spin` 0.9.8. - Allow `clippy::unused_async_trait_impl` (new in rustc 1.98) on the MCP `ServerHandler` impl, whose methods are async by the rmcp trait contract. +### Added + +- **Multi-user NetBox API-token pass-through.** `nbox serve --http + --netbox-token-passthrough` (or `[serve].netbox_token_passthrough = true`) + makes one nbox process serve many users: each caller presents their own NetBox + API token per request — in `X-NetBox-Token`, or in `Authorization` when that + header isn't already used by OIDC or `--http-token` — and nbox forwards it + verbatim to NetBox. NetBox's object permissions and changelog therefore apply + to the real caller, with no IdP and no `[serve.vault]` mapping. Reads are + cached per token fingerprint so one user's view is never served to another, + `nbox_cache_clear` only drops the caller's own partition, and `--allow-writes` + runs writes under the caller's token. A request without a caller token is + rejected with `401`, never silently downgraded to the server's profile token. + Tokens are redacted everywhere; the audit log records `auth=netbox-token` plus + a short SHA-256 `netbox_token_fp` and the per-caller rate-limit bucket keys on + it. Pass-through requires the HTTP transport (stdio has no per-request + headers, so asking for it there is a usage error) and allows binding a routable + address; terminate TLS in front of it. +- **Container image built from source.** A `Dockerfile` (multi-stage; the + existing `Dockerfile.release` only wraps prebuilt release binaries) and a + `docker-compose.yml` for running the multi-user pass-through server: + unprivileged uid 10001, read-only root filesystem, all capabilities dropped, + published on loopback for a TLS-terminating proxy to front, and no NetBox + credential baked into the image. ## [0.14.1] - 2026-07-31 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..befc41e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Build nbox from source and ship it as a small runtime image. +# +# This is the from-source counterpart to `Dockerfile.release` (which only wraps +# a prebuilt binary produced by the release matrix). It exists for running the +# MCP server yourself — most usefully in multi-user pass-through mode, where one +# container serves many users and each request carries its own NetBox token: +# +# docker build -t nbox:local . +# docker run --rm -p 8080:8080 -v ./nbox.toml:/etc/nbox/nbox.toml:ro nbox:local \ +# --config /etc/nbox/nbox.toml serve --http 0.0.0.0:8080 --netbox-token-passthrough +# +# See docs/MCP.md for the pass-through security model and header contract. + +FROM rust:1.98-bookworm AS builder +WORKDIR /src +COPY . . +RUN cargo build --release --locked --all-features + +FROM debian:bookworm-slim +# CA certificates only — nbox speaks HTTPS to NetBox and nothing else. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin nbox + +COPY --from=builder /src/target/release/nbox /usr/local/bin/nbox + +# Unprivileged: nbox needs no root, and in pass-through mode it holds no +# long-lived NetBox credential of its own that would be worth protecting with +# one — every request is authenticated by the caller's token. +USER 10001:10001 +EXPOSE 8080 + +# nbox reads its config from `--config `; pass one (see docker-compose.yml). +ENTRYPOINT ["nbox"] +CMD ["--help"] diff --git a/README.md b/README.md index 5ffb09d..65e4aca 100644 --- a/README.md +++ b/README.md @@ -590,7 +590,27 @@ nbox serve --http 0.0.0.0:8080 \ ``` This is accountability, not per-user RBAC — the last hop to NetBox still uses the -single profile token, so scope that token read-only. An audit log +single profile token, so scope that token read-only. For **real multi-user** +service, add `--netbox-token-passthrough`: each caller sends their own NetBox API +token (`X-NetBox-Token`, or `Authorization` when it is otherwise unused) and nbox +forwards it to NetBox, so NetBox's own permissions and changelog apply to the +real user — no IdP or credential vault needed. Reads are cached per caller so no +one sees another user's view, and a request without a token is rejected rather +than silently downgraded to the server's token: + +```bash +nbox serve --http 0.0.0.0:8080 --netbox-token-passthrough \ + --allowed-host nbox.example.com +``` + +The repo ships a `Dockerfile` (from source) and a `docker-compose.yml` for this +mode — unprivileged, read-only, no baked-in credential: + +```bash +NBOX_PUBLIC_HOST=nbox.example.com docker compose up -d --build +``` + +An audit log (`nbox::audit`) and an optional per-caller rate limit (`--rate-limit`) round it out. Full setup, security model, and IdP notes: [docs/MCP.md](docs/MCP.md). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9af5de1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +# Run the nbox MCP server in multi-user pass-through mode. +# +# docker compose up -d --build +# +# One container serves many users: each MCP client sends its *own* NetBox API +# token on every request (`X-NetBox-Token`, or `Authorization` when that header +# isn't otherwise in use), and nbox forwards it to NetBox. NetBox's own object +# permissions and changelog therefore apply to the real caller — the container +# holds no shared credential. A request without a token is rejected with 401. +# +# Point `examples/nbox-docker.toml` at your NetBox first (or set NBOX_CONFIG_FILE +# to your own config). See docs/MCP.md for the security model. + +services: + nbox: + build: . + image: nbox:local + container_name: nbox-mcp + restart: unless-stopped + ports: + # Defaults to loopback. Callers send real NetBox credentials on every + # request, so anything wider should be fronted by a TLS-terminating proxy; + # set NBOX_BIND=0.0.0.0 only on a network you trust. + - "${NBOX_BIND:-127.0.0.1}:${NBOX_PORT:-8080}:8080" + configs: + - source: nbox_config + target: /etc/nbox/nbox.toml + volumes: + # Only needed when NetBox is served by an internal CA; point `ca_bundle` + # in the config at /etc/nbox/ca.pem. Harmless when unset. + - ${NBOX_CA_BUNDLE:-/dev/null}:/etc/nbox/ca.pem:ro + command: + - --config + - /etc/nbox/nbox.toml + - serve + - --http + - 0.0.0.0:8080 + - --netbox-token-passthrough + # The public hostname clients use, for the Host/Origin (DNS-rebinding) + # check. Add one --allowed-host per name your proxy serves. + - --allowed-host + - ${NBOX_PUBLIC_HOST:-localhost} + - --rate-limit + - "120" + # No shared NetBox token is configured on purpose: in pass-through mode the + # caller's token is the only credential, and a missing one must fail rather + # than silently fall back to a server-side identity. + read_only: true + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + +configs: + nbox_config: + # A real file, not inline content: a `read_only` service can only take + # file-backed configs. Override with NBOX_CONFIG_FILE=/path/to/nbox.toml. + file: ${NBOX_CONFIG_FILE:-./examples/nbox-docker.toml} diff --git a/docs/MCP.md b/docs/MCP.md index ce37712..048f612 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -297,6 +297,119 @@ user's NetBox token. HTTP/static-bearer transports cannot use `local_writes` in this release. The `nbox_plan_write` / `nbox_apply_write` tools expose the same plan → confirm-token → apply lifecycle as the CLI. +## NetBox API-token pass-through (multi-user) + +The modes above all share **one** NetBox credential: whoever nbox is configured +with. `--netbox-token-passthrough` (or `[serve].netbox_token_passthrough = true`) +turns that around — each caller presents **their own NetBox API token** on every +request, and nbox forwards it verbatim to NetBox. One nbox process then serves +many users, and NetBox — not nbox — decides what each of them may see and change. + +```bash +nbox serve --http 0.0.0.0:8080 --netbox-token-passthrough \ + --allowed-host nbox.example.com +``` + +```toml +[serve] +http = "0.0.0.0:8080" +netbox_token_passthrough = true +``` + +This is the only mode where NetBox's own object permissions and changelog apply +to the real user. It needs no IdP, no credential vault, and no `[serve.vault]` +mapping: the token *is* the identity. + +### How a client sends its token + +nbox reads the caller's NetBox token from either header: + +| Header | When to use it | +| --- | --- | +| `X-NetBox-Token: ` | Always accepted. Required when `Authorization` is already taken. | +| `Authorization: Bearer ` / `Authorization: Token ` | Accepted only when `Authorization` isn't already in use by OIDC or `--http-token`. The `Bearer ` / `Token ` prefix is stripped before forwarding. | + +So a host that can only set a bearer works out of the box, and a host that needs +`Authorization` for a gateway can still pass the NetBox token beside it: + +```json +{ + "mcpServers": { + "nbox": { + "type": "http", + "url": "https://nbox.example.com/mcp", + "headers": { "X-NetBox-Token": "${NETBOX_TOKEN}" } + } + } +} +``` + +A request with no caller token is rejected with `401` before any MCP handling. +nbox **never** falls back to its own profile token for such a request — a +misconfigured client gets an error, not somebody else's privileges. + +### What the caller's token controls + +- **Reads** run under the caller's token, so NetBox filters results by that + user's permissions. +- **The read cache is partitioned per token**, so one user's cached view is never + served to another. This is a correctness requirement, not an optimization: + NetBox returns different results to different users for the same query. +- **`nbox_cache_clear` only clears the caller's own partition**, so no user can + evict everyone else's cached reads. +- **Writes** are still opt-in with `--allow-writes` (or `[serve].allow_writes = + true`). When enabled they run under the caller's token, which means NetBox's + object permissions gate them and its changelog attributes them to the real + user. The `nbox_plan_write` → `nbox_apply_write` confirm-token lifecycle is + unchanged. +- **The audit log** records a short, non-reversible fingerprint of the token + (`netbox_token_fp`, a SHA-256 prefix) and `auth=netbox-token`. Tokens are never + logged, printed, or included in error messages. +- The server's own profile token is unused for request handling in this mode, and + a configured `[serve.vault]` is ignored (nbox warns at startup) — the vault + exists to bridge OIDC identities to tokens, which pass-through makes redundant. + +### Running it in Docker + +The repo ships a from-source `Dockerfile` and a `docker-compose.yml` for exactly +this mode. Point `examples/nbox-docker.toml` at your NetBox (or set +`NBOX_CONFIG_FILE` to your own config), then: + +```bash +NBOX_PUBLIC_HOST=nbox.example.com docker compose up -d --build +``` + +The container runs unprivileged (uid 10001), read-only, with all capabilities +dropped, and publishes `127.0.0.1:8080` — put your TLS-terminating reverse proxy +in front of that and set `NBOX_PUBLIC_HOST` to the name it serves so the +`Host`/`Origin` check matches. No NetBox credential is baked into the image or +the config: the container is only useful once callers bring their own tokens. + +```bash +# Smoke test: no token must be refused, never served from a shared identity. +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8080/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}' +# => 401 +``` + +### Deployment notes + +- **Pass-through requires the HTTP transport.** stdio has no per-request headers, + so `nbox serve --netbox-token-passthrough` without `--http` is a usage error + rather than a silent no-op. +- **Terminate TLS in front of nbox.** Callers send real NetBox credentials on + every request; run it behind a reverse proxy with HTTPS, and set + `--allowed-host` to the public hostname so the `Host`/`Origin` checks match. +- Because pass-through servers are meant to be shared, binding a routable + address is allowed in this mode without the loopback restriction the + single-credential modes apply. +- **Rate limiting** (below) keys its per-caller bucket on the token fingerprint, + so one user cannot spend another user's budget. The coarse pre-auth bucket is + still per peer IP — behind a reverse proxy every caller shares that peer, so + size `--rate-limit` for the whole fleet or have the proxy do per-user limiting. + ## Operations (HTTP transport) Two operational features apply to the HTTP `/mcp` endpoint (not the @@ -359,7 +472,8 @@ When enabled it applies on two levels, both at `N`/minute: limiter and could hammer JWT validation unthrottled). The check is per peer IP, so one peer flooding never throttles another. - **Post-auth, per caller.** An authenticated request additionally honors a - per-caller bucket keyed on the OIDC `sub` (else `client_id`). This catches a + per-caller bucket keyed on the OIDC `sub` (else `client_id`, else — in + pass-through mode — the caller's NetBox-token fingerprint). This catches a single identity spread across many source IPs. A loopback / static-bearer caller has no token identity, so its peer-IP bucket diff --git a/docs/adr/0003-netbox-token-passthrough.md b/docs/adr/0003-netbox-token-passthrough.md new file mode 100644 index 0000000..cb9efbf --- /dev/null +++ b/docs/adr/0003-netbox-token-passthrough.md @@ -0,0 +1,124 @@ +# ADR-0003: NetBox API-token pass-through + +**Status:** Proposed +**Date:** 2026-10-06 +**Amends:** ADR-0001 §7 (MCP writes use an explicit write mode) + +## Context + +Every serve mode nbox has today reaches NetBox with **one** credential: the +active profile's token. The modes differ only in who they let *talk to nbox*: + +- **Pattern 2** (shared HTTP, OIDC) verifies the caller via an IdP JWT and maps + the `sub` through `[serve.vault]` to that user's NetBox token — real per-user + identity, at the cost of running an OIDC IdP and maintaining a vault table. +- **Pattern 3** (OIDC, read-only) verifies the caller but still queries NetBox + as the service account. `docs/MCP.md` calls this what it is: + *accountability, not per-user RBAC*. +- Static bearer and local stdio are single-credential by construction. + +So a shared nbox server cannot reflect NetBox's own permissions per user: two +callers with different NetBox roles see the same (service-account-filtered) +data. The only per-user escape hatch today is Pattern 2's vault, which +presupposes an IdP — disproportionate when NetBox already issues per-user API +tokens and already enforces object permissions, constraints, and change +logging on them. + +ADR-0001 §7 says MCP writes run "in one of two explicit modes" (OIDC+vault, or +local stdio). A third deployment shape needs a decision of the same rank, +because it changes what credential reaches NetBox. + +## Decision + +Add an opt-in multi-user mode, **NetBox API-token pass-through** (Pattern 4 in +the code, continuing the DESIGN §24 numbering), enabled by +`--netbox-token-passthrough` or `[serve].netbox_token_passthrough`: + +1. **The caller's NetBox token is the credential.** Each request carries the + caller's own NetBox API token in `X-NetBox-Token`, or in `Authorization` + when that header is not already used by OIDC or `--http-token` (a host that + can only set a bearer works; a JWT is never mistaken for a NetBox token). + nbox forwards it verbatim; NetBox's object permissions, constraints, and + change log apply to the real caller. No IdP, no vault. +2. **Fail closed.** A request without a caller token is rejected with `401` + and a body naming the expected header. There is no fallback to the profile + token — a misconfigured client gets an error, not someone else's rights. +3. **Per-token isolation is a correctness requirement, not an optimization.** + NetBox filters read results by permission, so the same query returns + different rows to different users: the read cache is partitioned per + token fingerprint (first 8 bytes of SHA-256, hex), and `nbox_cache_clear` + drops only the caller's own partition (no cross-tenant cache flush). +4. **Writes run under the caller's token, opt-in as before.** With + `--allow-writes`, the plan/apply two-step and audit of ADR-0001 run + unchanged, but the `PATCH`/`POST` to NetBox carries the caller's token + (`WriteMode::Passthrough`); the plan store binds plans to + `netbox-token:`. A configured `[serve.vault]` is ignored in + this mode, with a startup warning. +5. **HTTP transport only.** stdio has no per-request headers; requesting + pass-through there is a usage error (exit 2), not a silent no-op. +6. **A routable bind is allowed**, because a shared server is the point — + with the same condition as OIDC mode: terminate TLS in front. Callers send + a real NetBox credential on every request; plaintext off-host would leak + it. +7. **Attribution without exposure.** The token never appears in logs or + errors (hand-written `Debug` renders ``); the audit log records + `auth=netbox-token` plus the fingerprint, which also keys the post-auth + rate-limit bucket, so changing IP does not dodge the limit. + +Implementation follows the existing `RequestContext::extensions` handoff that +`write_caller_from_extensions()` already uses for the OIDC `Identity`: the HTTP +gate extracts the token and stores it in the request `Parts`; each tool call +builds its `RequestScope` from the extensions. Task-locals do not work here — +the session manager runs its own worker task and the service factory has no +request handle. + +## Consequences + +Positive: + +- A shared nbox deployment gets real per-user NetBox RBAC — reads, writes, + and the NetBox change log — with zero identity infrastructure beyond the + tokens NetBox already issues. +- The mode is opt-in and additive: every existing mode (Patterns 2 and 3, + static bearer, stdio) is byte-for-byte unchanged when the flag is absent. +- NetBox stays the single authorization authority; nbox adds no own ACL layer + that could drift from NetBox's. + +Negative / trade-offs (accepted): + +- **Plaintext tokens per request require TLS.** The mode is only as safe as + the transport in front of it; the loopback-publish default and the "put a + TLS-terminating proxy in front" documentation carry this. +- **The pre-auth rate-limit bucket stays keyed on the peer IP** — behind a + reverse proxy, unauthenticated floods share one bucket. This is existing + behavior shared with OIDC mode; fixing it needs a transport-level limit + (or proxy-side), not an nbox-side one, and is documented rather than + silently inherited. +- **nbox sees every caller token in memory** for the duration of a request. + Mitigations: never logged, never echoed, `Debug` redacted, per-fingerprint + audit only. Residual risk equals any API gateway fronting NetBox. +- **Token lifecycle is NetBox's.** A revoked or expired token fails at + request time; nbox has no way to pre-validate beyond forwarding, and does + not try. +- The `Authorization`-fallback placement is a heuristic (bearer-shaped + strings that are not OIDC/static-bearer are treated as NetBox tokens). With + OIDC or `--http-token` active it is disabled entirely, so the ambiguity it + resolves is bounded. + +Neutral: + +- Cache partitions per fingerprint mean one user evicting hot entries cannot + poison another's view; the cache was already per-profile-partitioned, so + the additional keying is a small step. +- "Pattern 4" continues the DESIGN §24 numbering; that document is not part + of the public repository, so the label is descriptive, not contractual. + +## References + +- ADR-0001 §7 — the two-mode MCP write decision this extends. +- ADR-0002 — the per-user-vault and local-stdio write modes; pass-through is + the third, vault-free multi-user mode. +- `docs/MCP.md` — the pass-through security model and the pre-auth rate-limit + note. +- [NetBox REST API authentication](https://netboxlabs.com/docs/netbox/integrations/rest-api/) + — per-user API tokens and object-level permissions. diff --git a/docs/adr/README.md b/docs/adr/README.md index e0d479b..523fbf8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,6 +10,7 @@ contributors understand why the design exists, not just what changed. |-----|-------|--------|------| | [0001](0001-safe-write-foundation.md) | Safe write foundation | Accepted | 2026-06-26 | | [0002](0002-local-single-user-mcp-writes.md) | Local single-user MCP writes | Accepted | 2026-06-29 | +| [0003](0003-netbox-token-passthrough.md) | NetBox API-token pass-through | Proposed | 2026-10-06 | ## Template diff --git a/examples/config.toml b/examples/config.toml index dda9460..ce13636 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -61,3 +61,7 @@ verify_tls = false # labs with self-signed certs; never use in prod # jwks_url = "https://idp.example.com/keys" # absent = discover from issuer # allowed_hosts = ["nbox.example.com"] # extra DNS-rebinding allow-list hosts # rate_limit = 120 # per-caller requests/min (0/absent = off) +# Multi-user: every caller sends their OWN NetBox API token (X-NetBox-Token, or +# Authorization when it is otherwise unused) and nbox forwards it to NetBox, so +# NetBox's permissions and changelog apply to the real user. Requires `http`. +# netbox_token_passthrough = true diff --git a/examples/nbox-docker.toml b/examples/nbox-docker.toml new file mode 100644 index 0000000..759fa2f --- /dev/null +++ b/examples/nbox-docker.toml @@ -0,0 +1,16 @@ +# Config for the containerized MCP server in multi-user pass-through mode +# (see docker-compose.yml). Point `url` at your NetBox and you're done. +# +# There is deliberately no token here: in pass-through mode every caller sends +# their own NetBox API token per request, and a request without one is rejected +# rather than falling back to a shared server-side credential. + +config_version = 1 +active_profile = "netbox" + +[profiles.netbox] +url = "https://netbox.example.com" +# verify_tls = false # only for labs with self-signed certs + +[serve] +netbox_token_passthrough = true diff --git a/skills/serve/SKILL.md b/skills/serve/SKILL.md index 33ab389..e6f0528 100644 --- a/skills/serve/SKILL.md +++ b/skills/serve/SKILL.md @@ -29,6 +29,15 @@ For the flags, run `nbox serve --help` — this skill is flag-free by design. and an opt-in per-caller rate limit. This is **read-only Pattern 3**: the last hop to NetBox still uses the one local profile token, so the audit log is accountability, not per-user RBAC — trusted single-team read-only only. +- **HTTP, multi-user** — `nbox serve --http 0.0.0.0:8080 + --netbox-token-passthrough` (or `[serve].netbox_token_passthrough = true`) + makes one process serve many users: each caller sends their **own** NetBox API + token per request, in `X-NetBox-Token` or in `Authorization` when that header + isn't already used by OIDC / `--http-token`, and nbox forwards it to NetBox. + NetBox's permissions and changelog then apply to the real caller — no IdP, no + `[serve.vault]`. Reads are cached per token so nobody sees another user's view; + a request with no token is `401`, never downgraded to the server's token. + Requires `--http` (stdio has no per-request headers); terminate TLS in front. ## The read tools @@ -75,6 +84,7 @@ config. nbox serve --print-config # paste-ready mcpServers JSON, then exit nbox serve # stdio, read-only nbox serve --http 127.0.0.1:8080 # loopback HTTP, read-only +nbox serve --http 0.0.0.0:8080 --netbox-token-passthrough # multi-user: caller's own token ``` ## Writes are a separate opt-in @@ -84,8 +94,10 @@ The MCP server is read-only by default. The write tools (`nbox_plan_write` / one explicit write mode: local stdio `nbox serve --local-writes`, which uses the active profile token, or shared HTTP/OIDC `nbox serve --http --allow-writes` plus the caller's `nbox:write` scope and a `[serve.vault]` entry mapping their OIDC -`sub` to a per-user NetBox token. HTTP/static-bearer profile-token writes reject -in this release. +`sub` to a per-user NetBox token. With `--netbox-token-passthrough`, +`--allow-writes` is enough on its own — the write runs under the caller's own +NetBox token, so NetBox authorizes and attributes it. HTTP/static-bearer +profile-token writes reject in this release. `nbox_apply_write` applies the plan the server stored at plan time (looked up by the `confirm_token` from `nbox_plan_write`), not the plan you resubmit. For that lifecycle, see the [safe writes](../writes/SKILL.md) skill. diff --git a/src/cli.rs b/src/cli.rs index ec3dc94..1f1f84f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -608,6 +608,18 @@ pub enum Command { #[arg(long, value_name = "N")] rate_limit: Option, + /// Serve many users from one instance: require each caller to present + /// their own NetBox API token on every HTTP request and forward it + /// verbatim to NetBox, instead of using the profile's service token. + /// The token rides in `X-NetBox-Token`, or in `Authorization: + /// Bearer|Token ` when nothing else claims that header. NetBox's + /// own object permissions then apply per user, and its change log names + /// the real human. Only meaningful with `--http`; it also permits a + /// routable bind (terminate TLS in front). Also read from + /// `[serve].netbox_token_passthrough`. + #[arg(long = "netbox-token-passthrough")] + netbox_token_passthrough: bool, + /// Enable MCP write tools (Pattern 2, DESIGN §24). Requires the `http` /// feature and `--http` (writes need the HTTP transport so the OIDC /// caller identity can be resolved to a per-user NetBox token via the @@ -1243,6 +1255,7 @@ mod tests { "--audience", "--oidc-jwks-url", "--rate-limit", + "--netbox-token-passthrough", // global "--log-file", // search @@ -1328,6 +1341,7 @@ mod tests { r"\-\-audience", r"\-\-oidc\-jwks\-url", r"\-\-rate\-limit", + r"\-\-netbox\-token\-passthrough", r"\-\-print\-config", ] { assert!(serve.contains(flag), "serve man page missing `{flag}`"); @@ -1693,6 +1707,40 @@ mod tests { )); } + #[test] + fn serve_parses_the_netbox_token_passthrough_flag() { + let cli = Cli::try_parse_from([ + "nbox", + "--no-tui", + "serve", + "--http", + "0.0.0.0:8080", + "--netbox-token-passthrough", + ]) + .unwrap(); + let Some(Command::Serve { + http, + netbox_token_passthrough, + .. + }) = cli.command + else { + panic!("expected serve"); + }; + assert_eq!(http.as_deref(), Some("0.0.0.0:8080")); + assert!(netbox_token_passthrough); + + // Off unless asked for: the default stays single-credential. + let plain = Cli::try_parse_from(["nbox", "--no-tui", "serve"]).unwrap(); + let Some(Command::Serve { + netbox_token_passthrough, + .. + }) = plain.command + else { + panic!("expected serve"); + }; + assert!(!netbox_token_passthrough); + } + #[test] fn device_set_status_parses_flags() { let set = Cli::try_parse_from([ diff --git a/src/config.rs b/src/config.rs index b3d9de8..c6f9d4a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -138,6 +138,13 @@ pub struct ServeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub rate_limit: Option, + /// Serve many users from one HTTP instance: each caller presents their own + /// NetBox API token on every `/mcp` request and nbox forwards it verbatim, + /// instead of using the profile's service token. See + /// [`crate::mcp::passthrough`]. Overridden by `--netbox-token-passthrough`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub netbox_token_passthrough: bool, + /// Whether to enable write tools on the MCP server (Pattern 2, DESIGN §24). /// When `false` (the default), all write tools reject with "writes /// disabled". When `true`, the operator must also provision @@ -174,6 +181,7 @@ impl std::fmt::Debug for ServeConfig { .field("jwks_url", &self.jwks_url) .field("allowed_hosts", &self.allowed_hosts) .field("rate_limit", &self.rate_limit) + .field("netbox_token_passthrough", &self.netbox_token_passthrough) .field("allow_writes", &self.allow_writes) .field("local_writes", &self.local_writes) .field("vault_entries", &self.vault.len()) @@ -1599,11 +1607,28 @@ search = "graphql" ) .unwrap(); assert!(allow_only.serve.allow_writes); + assert!( + !allow_only.serve.netbox_token_passthrough, + "multi-user pass-through must be opt-in, never implied by allow_writes" + ); assert!( !allow_only.serve.local_writes, "allow_writes must not imply local_writes" ); + let passthrough: Config = toml::from_str( + "active_profile = \"work\"\n\ + \n\ + [serve]\n\ + http = \"0.0.0.0:8080\"\n\ + netbox_token_passthrough = true\n\ + \n\ + [profiles.work]\n\ + url = \"https://netbox.example.com\"\n", + ) + .unwrap(); + assert!(passthrough.serve.netbox_token_passthrough); + // The OIDC resource-server fields parse onto the same section. let oidc: Config = toml::from_str( "active_profile = \"work\"\n\ diff --git a/src/lib.rs b/src/lib.rs index b907cb2..e0f1062 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -598,6 +598,7 @@ pub async fn run(cli: Cli) -> Result<()> { oidc_jwks_url, allowed_host, rate_limit, + netbox_token_passthrough, allow_writes, local_writes, print_config, @@ -612,6 +613,7 @@ pub async fn run(cli: Cli) -> Result<()> { oidc_jwks_url, allowed_host, rate_limit, + netbox_token_passthrough, allow_writes, local_writes, print_config, @@ -748,6 +750,10 @@ struct ServeFlags { allowed_host: Vec, /// Per-caller requests-per-minute cap; `None` ⇒ fall back to config / off. rate_limit: Option, + /// `--netbox-token-passthrough`: multi-user mode — each HTTP caller presents + /// their own NetBox API token, which nbox forwards to NetBox for that + /// request instead of using the profile's service token. + netbox_token_passthrough: bool, /// `--print-config`: print the `mcpServers` snippet and exit (no connect). /// `--allow-writes`: enable MCP write tools (Pattern 2). `false` (the /// default) keeps the server read-only. @@ -794,6 +800,10 @@ async fn run_serve(ctx: &Ctx, flags: ServeFlags) -> Result<()> { // Per-caller rate limit: flag wins, then config, then off (0). Absent / 0 = // disabled, so existing behavior is unchanged unless the operator opts in. let rate_limit = flags.rate_limit.or(serve_cfg.rate_limit).unwrap_or(0); + // Multi-user NetBox API-token pass-through: flag OR config (either is an + // explicit opt-in). + let netbox_token_passthrough = + flags.netbox_token_passthrough || serve_cfg.netbox_token_passthrough; // Shared HTTP writes require both the flag/config gate and the HTTP // transport (OIDC identity → per-user token resolution via the vault). let allow_writes = flags.allow_writes || serve_cfg.allow_writes; @@ -819,6 +829,19 @@ async fn run_serve(ctx: &Ctx, flags: ServeFlags) -> Result<()> { (None, _) => None, }; + // Pass-through is a property of the HTTP transport's request headers: stdio + // has no per-request credential to read, so asking for it there is a usage + // error rather than a silent no-op that quietly keeps the service token. + if netbox_token_passthrough && http.is_none() { + return Err(error::NboxError::Usage( + "`--netbox-token-passthrough` / [serve].netbox_token_passthrough requires the \ + HTTP transport — pass --http . The stdio transport has no per-request \ + headers, so there is no caller token to forward." + .to_string(), + ) + .into()); + } + if http.is_some() && local_writes { return Err(error::NboxError::Usage( "`--local-writes` / [serve].local_writes is only supported on the stdio MCP \ @@ -836,7 +859,15 @@ async fn run_serve(ctx: &Ctx, flags: ServeFlags) -> Result<()> { // Build the per-user credential vault for the shared HTTP/OIDC write path. // The vault maps OIDC `sub` → env var name holding a per-user NetBox token. // Local stdio writes intentionally do not use the vault. - let vault = if allow_writes && http.is_some() { + // Pass-through mode has no vault: the caller's own token is their + // credential, so there is nothing to map a `sub` onto. + if netbox_token_passthrough && !serve_cfg.vault.is_empty() { + tracing::warn!( + "[serve.vault] entries are ignored in NetBox API-token pass-through mode — \ + each caller writes with the token they present" + ); + } + let vault = if allow_writes && http.is_some() && !netbox_token_passthrough { Some(mcp::vault::CredentialVault::new(serve_cfg.vault, true)) } else { None @@ -856,6 +887,8 @@ async fn run_serve(ctx: &Ctx, flags: ServeFlags) -> Result<()> { cache, vault, ctx.profile.clone().unwrap_or_default(), + netbox_token_passthrough, + allow_writes, ) .await } @@ -950,6 +983,8 @@ async fn serve_http_or_explain( cache: cache::Cache, vault: Option, profile: String, + netbox_token_passthrough: bool, + allow_writes: bool, ) -> Result<()> { let oidc = oidc.map(|(issuer, audience)| mcp::OidcArgs { issuer, @@ -967,6 +1002,8 @@ async fn serve_http_or_explain( cache, vault, profile, + netbox_token_passthrough, + allow_writes, }, ) .await @@ -988,6 +1025,8 @@ async fn serve_http_or_explain( _cache: cache::Cache, _vault: Option, _profile: String, + _netbox_token_passthrough: bool, + _allow_writes: bool, ) -> Result<()> { Err(error::NboxError::Usage( "`nbox serve --http` requires the `http` build feature, which this binary \ diff --git a/src/mcp/audit.rs b/src/mcp/audit.rs index 0f0676b..04e2c43 100644 --- a/src/mcp/audit.rs +++ b/src/mcp/audit.rs @@ -40,6 +40,10 @@ pub enum AuthMode { StaticBearer, /// OIDC resource-server mode — a validated IdP JWT. Oidc, + /// NetBox API-token pass-through — the caller presented their own NetBox + /// token, which nbox forwards to NetBox for that request. Attribution is the + /// token's fingerprint, never the token. + NetBoxToken, } impl AuthMode { @@ -49,6 +53,7 @@ impl AuthMode { AuthMode::Loopback => "loopback", AuthMode::StaticBearer => "static-bearer", AuthMode::Oidc => "oidc", + AuthMode::NetBoxToken => "netbox-token", } } } @@ -94,7 +99,27 @@ impl Outcome { /// /// The IP fallback is prefixed (`ip:`) so an IP-keyed caller can never collide /// with a `sub`/`client_id` that happens to look like an address. +/// +/// See [`caller_key_with_token`] for the pass-through-aware variant — in that +/// mode the caller's NetBox token fingerprint is the identity. pub fn caller_key(identity: Option<&Identity>, peer: Option) -> String { + caller_key_with_token(identity, None, peer) +} + +/// [`caller_key`], plus the NetBox API-token pass-through identity. +/// +/// Precedence: `sub` → `client_id` → the caller's NetBox token fingerprint → +/// the peer IP. The token fingerprint slots *below* the OIDC identity (a `sub` +/// names the human; a fingerprint only names a credential) but *above* the peer +/// IP, so several pass-through users behind one proxy get their own audit +/// attribution and their own rate-limit bucket instead of sharing the proxy's +/// address. The raw token is never part of the key — only its fingerprint (see +/// [`PassthroughToken::fingerprint`](crate::mcp::passthrough::PassthroughToken::fingerprint)). +pub fn caller_key_with_token( + identity: Option<&Identity>, + netbox_token: Option<&crate::mcp::passthrough::PassthroughToken>, + peer: Option, +) -> String { if let Some(id) = identity { if let Some(sub) = id.sub.as_deref().filter(|s| !s.is_empty()) { return format!("sub:{sub}"); @@ -103,6 +128,9 @@ pub fn caller_key(identity: Option<&Identity>, peer: Option) -> String { return format!("client:{client}"); } } + if let Some(token) = netbox_token { + return token.caller_key(); + } match peer { Some(ip) => format!("ip:{ip}"), None => "ip:unknown".to_string(), @@ -161,6 +189,11 @@ pub struct AuditEvent<'a> { /// (see [`session_hash`]). Correlatable across a session's requests, but the /// raw session token never lands in the log. pub session: Option<&'a str>, + /// The fingerprint of the caller's pass-through NetBox API token, when the + /// server runs in NetBox-token pass-through mode. A stable opaque label — + /// never the token itself (see + /// [`PassthroughToken::fingerprint`](crate::mcp::passthrough::PassthroughToken::fingerprint)). + pub netbox_token_fp: Option<&'a str>, /// Response status code. pub status: u16, /// Coarse outcome. @@ -188,6 +221,7 @@ impl AuditEvent<'_> { method = self.method, path = self.path, session = self.session, + netbox_token_fp = self.netbox_token_fp, status = self.status, outcome = self.outcome.as_str(), latency_ms = self.latency_ms, @@ -577,6 +611,7 @@ mod tests { method: "POST", path: "/mcp", session: Some("0123456789abcdef"), + netbox_token_fp: None, status: 200, outcome: Outcome::Ok, latency_ms: 12, diff --git a/src/mcp/http.rs b/src/mcp/http.rs index e39734e..b96e82c 100644 --- a/src/mcp/http.rs +++ b/src/mcp/http.rs @@ -61,6 +61,7 @@ use super::oidc::{ self, AuthError, Identity, JwksCache, OidcConfig, SCOPE_READ, SCOPE_WRITE, require_https_or_loopback, }; +use super::passthrough::{self, PassthroughToken, Placement}; use crate::error::NboxError; use crate::netbox::client::NetBoxClient; @@ -192,6 +193,23 @@ struct GateState { guard: Guard, allowed_hosts: Arc, rate_limiter: Option>, + /// `Some` ⇒ NetBox API-token pass-through is on, and this is where the + /// caller's token may ride. `None` ⇒ single-credential mode (the profile's + /// service token does the last hop), exactly as before. + passthrough: Option, +} + +impl GateState { + /// The [`AuthMode`] for a request rejected before auth resolves (the pre-auth + /// peer-IP 429). Defers to the guard, except that a pass-through-only + /// deployment (no OIDC, no static bearer) is honestly `netbox-token` rather + /// than `loopback` — the caller's NetBox token is its one credential. + fn unauth_mode(&self) -> AuthMode { + match self.guard.unauth_mode() { + AuthMode::Loopback if self.passthrough.is_some() => AuthMode::NetBoxToken, + mode => mode, + } + } } /// Operational inputs for [`serve_http`], grouped so the call site stays one @@ -217,6 +235,16 @@ pub struct ServeOptions { /// The active profile name — bound into the confirmation token so a plan /// from one profile can't be applied under another. pub profile: String, + /// NetBox API-token pass-through (multi-user mode). When `true`, every + /// `/mcp` request must carry the caller's own NetBox API token, which nbox + /// forwards verbatim to NetBox for that request — so one server instance + /// serves many users under their own NetBox identity and permissions, and + /// the profile's service token is never used for their reads or writes. + pub netbox_token_passthrough: bool, + /// Whether write tools are enabled. Only consulted in pass-through mode + /// (the caller's own token authorizes the write, so no vault is involved); + /// the vault path keeps using `vault`'s own gate. + pub allow_writes: bool, } /// Serve the MCP server over HTTP until interrupted. @@ -236,23 +264,35 @@ pub async fn serve_http(client: NetBoxClient, addr: &str, opts: ServeOptions) -> cache, vault, profile, + netbox_token_passthrough, + allow_writes, } = opts; let oidc_on = oidc.is_some(); - let socket = parse_bind_addr(addr, oidc_on)?; - if oidc_on && !is_loopback(socket.ip()) { + // Pass-through is a second, independent reason a routable bind is safe: every + // caller authenticates to NetBox with their own credential, so nbox holds no + // ambient authority to expose. Both modes still need TLS in front. + let routable_ok = oidc_on || netbox_token_passthrough; + let socket = parse_bind_addr(addr, routable_ok)?; + if routable_ok && !is_loopback(socket.ip()) { tracing::warn!( %socket, "binding a non-loopback address — terminate TLS in front (reverse proxy); \ - nbox serves plain HTTP and validates inbound IdP JWTs but does not do TLS" + nbox serves plain HTTP and does not do TLS" ); } + // Where the caller's NetBox token may ride. `Authorization` is off-limits + // when it already carries another credential (an OIDC JWT or the static + // bearer) — forwarding a JWT to NetBox as an API token would be a bug. + let placement = netbox_token_passthrough.then(|| Placement::resolve(oidc_on, token.is_some())); // Build the allowed-host set for the DNS-rebinding defense. Loopback mode is // strict (loopback-only — operator `--allowed-host` is ignored there, by // design). OIDC mode adds the `--audience` host (nbox's own identity) plus any // `--allowed-host` entries, so a real proxied request with the deployment's - // `Host` passes both rmcp's check and our `Origin` check. - let allowed_hosts = if oidc_on { + // `Host` passes both rmcp's check and our `Origin` check. Pass-through has no + // audience of its own, so its routable deployments name their host(s) with + // `--allowed-host`. + let allowed_hosts = if routable_ok { let audience = oidc.as_ref().map(|a| a.audience.as_str()); let allowed = build_allowed_hosts(audience, &extra_hosts)?; if !allowed.extra.is_empty() { @@ -268,7 +308,7 @@ pub async fn serve_http(client: NetBoxClient, addr: &str, opts: ServeOptions) -> if !extra_hosts.is_empty() { tracing::warn!( "--allowed-host is ignored in loopback mode (the allow-list stays loopback-only); \ - it applies only with --oidc-issuer" + it applies only with --oidc-issuer or --netbox-token-passthrough" ); } AllowedHosts::default() @@ -292,12 +332,23 @@ pub async fn serve_http(client: NetBoxClient, addr: &str, opts: ServeOptions) -> guard: guard.clone(), allowed_hosts: allowed_hosts.clone(), rate_limiter, + passthrough: placement, }; // Build the server once; the service factory hands rmcp a fresh clone per // session (cheap — `NboxMcp` holds an `Arc` and a cheaply-cloned // `Cache` sharing one store, so all sessions share the cache). - let server = NboxMcp::new(client, cache, vault, profile); + let server = if netbox_token_passthrough { + tracing::info!( + placement = ?placement, + writes = allow_writes, + "NetBox API-token pass-through enabled — each caller's own token is \ + forwarded to NetBox (the profile's service token is not used for tool calls)" + ); + NboxMcp::new_passthrough(client, cache, profile, allow_writes) + } else { + NboxMcp::new(client, cache, vault, profile) + }; let cancel = CancellationToken::new(); // `StreamableHttpServerConfig` is `#[non_exhaustive]`, so build from the @@ -393,22 +444,24 @@ async fn build_oidc_config(args: OidcArgs) -> Result { }) } -/// Parse `addr` to a [`SocketAddr`]. In loopback mode (`!oidc`) it must be a -/// loopback address; a routable bind is rejected as a [`NboxError::Usage`] (exit -/// `2`) pointing at `--oidc-issuer`. In OIDC mode any address is allowed (the -/// caller warns about TLS for non-loopback binds). -fn parse_bind_addr(addr: &str, oidc: bool) -> Result { +/// Parse `addr` to a [`SocketAddr`]. Without a per-caller auth mode +/// (`!routable_ok`) it must be a loopback address; a routable bind is rejected as +/// a [`NboxError::Usage`] (exit `2`) pointing at `--oidc-issuer` / +/// `--netbox-token-passthrough`. With one, any address is allowed (the caller +/// warns about TLS for non-loopback binds). +fn parse_bind_addr(addr: &str, routable_ok: bool) -> Result { let socket: SocketAddr = addr.parse().map_err(|_| { NboxError::Usage(format!( "--http expects an IP:PORT address, e.g. 127.0.0.1:8080 (got \"{addr}\")" )) })?; - if !oidc && !is_loopback(socket.ip()) { + if !routable_ok && !is_loopback(socket.ip()) { return Err(NboxError::Usage(format!( "--http {addr} is not a loopback address. Binding a routable interface \ - requires the OIDC resource-server auth mode — pass --oidc-issuer \ - and --audience (and terminate TLS in front). Loopback (127.0.0.0/8 \ - or ::1) needs neither." + requires a per-caller auth mode — either the OIDC resource server \ + (--oidc-issuer and --audience ) or NetBox API-token \ + pass-through (--netbox-token-passthrough), and TLS terminated in front. \ + Loopback (127.0.0.0/8 or ::1) needs neither." )) .into()); } @@ -628,6 +681,7 @@ async fn gate(State(state): State, request: Request, next: Next let GateOutcome { auth_mode, identity, + netbox_token, mut response, } = gate_inner(&state, request, next).await; @@ -641,6 +695,7 @@ async fn gate(State(state): State, request: Request, next: Next &request_id, auth_mode, identity.as_ref(), + netbox_token.as_ref(), peer, &method, &path, @@ -657,6 +712,9 @@ async fn gate(State(state): State, request: Request, next: Next struct GateOutcome { auth_mode: AuthMode, identity: Option, + /// The caller's NetBox API token in pass-through mode, for attribution. Only + /// its fingerprint is ever logged. + netbox_token: Option, response: Response, } @@ -686,8 +744,9 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - // The request hasn't authenticated yet; attribute the 429 to the mode the // guard would use and no identity (no secret to leak). return GateOutcome { - auth_mode: state.guard.unauth_mode(), + auth_mode: state.unauth_mode(), identity: None, + netbox_token: None, response: too_many_requests(retry_after_secs), }; } @@ -708,6 +767,7 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - return GateOutcome { auth_mode: AuthMode::StaticBearer, identity: None, + netbox_token: None, response: loopback_unauthorized(), }; } @@ -729,6 +789,7 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - return GateOutcome { auth_mode: AuthMode::Oidc, identity: Some(identity), + netbox_token: None, response: oidc_challenge(cfg, &scope_err), }; } @@ -738,6 +799,7 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - return GateOutcome { auth_mode: AuthMode::Oidc, identity: None, + netbox_token: None, response: oidc_challenge(cfg, &e), }; } @@ -745,6 +807,37 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - } }; + // 1b) NetBox API-token pass-through: resolve the caller's own NetBox token. + // This is what makes the server multi-user — the token is forwarded + // verbatim on the last hop, so NetBox's object permissions (and its + // change log) apply per user instead of per service account. It is an + // auth factor in its own right: no token ⇒ 401 here, before any NetBox + // call and before the request can reach a tool. + // + // When pass-through is off this is `None` and nothing downstream + // changes — the profile's service token still does the last hop. + let netbox_token = match state.passthrough { + Some(placement) => match passthrough::extract(request.headers(), placement) { + Some(token) => Some(token), + None => { + return GateOutcome { + auth_mode: AuthMode::NetBoxToken, + identity, + netbox_token: None, + response: missing_netbox_token(placement), + }; + } + }, + None => None, + }; + // Record the pass-through mode when it is the *only* caller credential; an + // OIDC/static-bearer deployment keeps naming its own outer factor. + let auth_mode = if netbox_token.is_some() && matches!(auth_mode, AuthMode::Loopback) { + AuthMode::NetBoxToken + } else { + auth_mode + }; + // 2) Origin validation (DNS-rebinding defense), in BOTH modes. A request that // carries an `Origin` header must have an allowed host — the SAME set // rmcp's `Host` check uses. In loopback mode that is loopback-only; in @@ -761,6 +854,7 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - return GateOutcome { auth_mode, identity, + netbox_token, response: forbidden(), }; } @@ -773,7 +867,7 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - // identity), skip it — that single logical request is not charged twice to // the one bucket. An OIDC `sub`/`client` caller has a distinct bucket, so it // honors both the coarse peer-IP cap and its own per-caller cap. - let caller = audit::caller_key(identity.as_ref(), peer); + let caller = audit::caller_key_with_token(identity.as_ref(), netbox_token.as_ref(), peer); if caller != peer_key && let Some(rl) = &state.rate_limiter && let RateDecision::Limited { retry_after_secs } = rl.check(&caller) @@ -781,6 +875,7 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - return GateOutcome { auth_mode, identity, + netbox_token, response: too_many_requests(retry_after_secs), }; } @@ -790,11 +885,19 @@ async fn gate_inner(state: &GateState, mut request: Request, next: Next) - if let Some(id) = &identity { request.extensions_mut().insert(id.clone()); } + // Same handoff for the pass-through token: the tool layer reads it back out + // of the originating request `Parts` and builds a per-request NetBox client + // from it. It is deliberately NOT bound to the MCP session — every request + // carries (and is authorized by) its own token. + if let Some(token) = &netbox_token { + request.extensions_mut().insert(token.clone()); + } let response = next.run(request).await; GateOutcome { auth_mode, identity, + netbox_token, response, } } @@ -822,6 +925,7 @@ fn audit( request_id: &str, auth: AuthMode, identity: Option<&Identity>, + netbox_token: Option<&PassthroughToken>, peer: Option, method: &str, path: &str, @@ -830,7 +934,7 @@ fn audit( start: Instant, ) { let status = response.status().as_u16(); - let caller = audit::caller_key(identity, peer); + let caller = audit::caller_key_with_token(identity, netbox_token, peer); // Space-join the scopes for a compact, greppable field; `None` when empty. let scope = identity.and_then(|id| { if id.scopes.is_empty() { @@ -851,6 +955,7 @@ fn audit( method, path, session, + netbox_token_fp: netbox_token.map(PassthroughToken::fingerprint), status, outcome: Outcome::from_status(status), latency_ms: start.elapsed().as_millis(), @@ -980,6 +1085,29 @@ fn loopback_unauthorized() -> Response { response } +/// 401 for a pass-through request that carried no usable NetBox API token. +/// +/// The body names the accepted headers for the deployment's [`Placement`], so a +/// client that put the token in the wrong place is told where it belongs. The +/// challenge is `Bearer` with `invalid_request` (RFC 6750) — the credential is +/// missing, not rejected by NetBox; a token NetBox itself refuses surfaces as +/// the tool's own 401/403 error instead. +fn missing_netbox_token(placement: Placement) -> Response { + let mut response = Response::new(Body::from(format!( + "Unauthorized: this nbox server runs in NetBox API-token pass-through mode — {}", + placement.hint() + ))); + *response.status_mut() = StatusCode::UNAUTHORIZED; + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + header::HeaderValue::from_static( + "Bearer realm=\"nbox\", error=\"invalid_request\", \ + error_description=\"a NetBox API token is required\"", + ), + ); + response +} + /// 403 for a rejected (non-loopback / malformed) `Origin`. fn forbidden() -> Response { let mut response = Response::new(Body::from("Forbidden: Origin not allowed")); @@ -1577,19 +1705,31 @@ mod rs_tests { /// A stub `/mcp` handler standing in for the rmcp service. Returns 200 and /// echoes the validated identity (proving the gate plumbed it into the /// request extensions) when present. - async fn stub_mcp(identity: Option>) -> Response { - match identity { - Some(Extension(id)) => { - let body = format!( - "ok sub={} client={} scopes={}", - id.sub.as_deref().unwrap_or("-"), - id.client_id.as_deref().unwrap_or("-"), - id.scopes.join(",") - ); - (StatusCode::OK, body).into_response() - } - None => (StatusCode::OK, "ok").into_response(), + async fn stub_mcp( + identity: Option>, + netbox_token: Option>, + ) -> Response { + let mut body = match identity { + Some(Extension(id)) => format!( + "ok sub={} client={} scopes={}", + id.sub.as_deref().unwrap_or("-"), + id.client_id.as_deref().unwrap_or("-"), + id.scopes.join(",") + ), + None => "ok".to_string(), + }; + // Echo the pass-through handoff so tests can assert the gate reached the + // inner service with (and only with) the caller's own token. + if let Some(Extension(token)) = netbox_token { + use std::fmt::Write as _; + let _ = write!( + body, + " nbtoken={} raw={}", + token.fingerprint(), + token.as_str() + ); } + (StatusCode::OK, body).into_response() } /// The gated router under test: the real `gate` + PRM route over a stub `/mcp`. @@ -1636,11 +1776,22 @@ mod rs_tests { guard: Guard, rate_limiter: Option>, allowed_hosts: AllowedHosts, + ) -> Router { + router_with_passthrough(guard, rate_limiter, allowed_hosts, None) + } + + /// Build the gated router with NetBox API-token pass-through configured. + fn router_with_passthrough( + guard: Guard, + rate_limiter: Option>, + allowed_hosts: AllowedHosts, + passthrough: Option, ) -> Router { let state = GateState { guard: guard.clone(), allowed_hosts: Arc::new(allowed_hosts), rate_limiter, + passthrough, }; let mut router = Router::new() .route("/mcp", any(stub_mcp)) @@ -2716,4 +2867,171 @@ mod rs_tests { // The old field name is gone (it's now `session`). assert_eq!(e.get("session_id"), None); } + // --------------------------------------------------------------------- + // NetBox API-token pass-through (multi-user mode) + // --------------------------------------------------------------------- + + /// A `GET /mcp` request carrying the given raw headers. + fn mcp_request_with(headers: &[(&str, &str)]) -> Request { + let mut builder = Request::builder().uri("/mcp").method("GET"); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(Body::empty()).unwrap() + } + + /// The pass-through router: no other auth factor, so `Authorization` is free. + fn passthrough_router() -> Router { + router_with_passthrough( + Guard::Loopback { token: None }, + None, + AllowedHosts::default(), + Some(Placement::Authorization), + ) + } + + #[tokio::test] + async fn passthrough_rejects_a_request_with_no_netbox_token() { + let (status, www, body) = send(passthrough_router(), mcp_request(None)).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "a pass-through server must not serve an anonymous request: {body}" + ); + let www = www.expect("a WWW-Authenticate challenge"); + assert!(www.contains("invalid_request"), "{www}"); + // The body tells the client where the token belongs. + assert!(body.contains("X-NetBox-Token"), "{body}"); + } + + #[tokio::test] + async fn passthrough_forwards_the_callers_token_to_the_inner_service() { + for header_pair in [ + ("x-netbox-token", "nbt_alice"), + ("authorization", "Bearer nbt_alice"), + ("authorization", "Token nbt_alice"), + ] { + let (status, _www, body) = + send(passthrough_router(), mcp_request_with(&[header_pair])).await; + assert_eq!(status, StatusCode::OK, "for {header_pair:?}: {body}"); + assert!( + body.contains("raw=nbt_alice"), + "the caller's own token must reach the tool layer for {header_pair:?}: {body}" + ); + } + } + + #[tokio::test] + async fn passthrough_with_a_static_bearer_requires_the_dedicated_header() { + // Authorization carries nbox's own static bearer; the NetBox token must + // therefore ride in X-NetBox-Token and must never be read from + // Authorization (that would forward nbox's bearer to NetBox). + let router = router_with_passthrough( + Guard::Loopback { + token: Some(Arc::from("s3cret")), + }, + None, + AllowedHosts::default(), + Some(Placement::DedicatedHeaderOnly), + ); + let (status, _www, body) = send( + router.clone(), + mcp_request_with(&[("authorization", "Bearer s3cret")]), + ) + .await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "the static bearer alone is not a NetBox token: {body}" + ); + + let (status, _www, body) = send( + router, + mcp_request_with(&[ + ("authorization", "Bearer s3cret"), + ("x-netbox-token", "nbt_alice"), + ]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body.contains("raw=nbt_alice"), "{body}"); + } + + #[tokio::test] + async fn passthrough_audits_the_fingerprint_and_never_the_token() { + let (events, _guard) = capture(); + let (status, _www, _body) = send( + passthrough_router(), + mcp_request_with(&[("x-netbox-token", "nbt_supersecret")]), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let events = events.lock().unwrap(); + assert_eq!(events.len(), 1); + let e = &events[0]; + let expected = PassthroughToken::new("nbt_supersecret").unwrap(); + assert_eq!(e.get("auth"), Some("netbox-token")); + assert_eq!(e.get("netbox_token_fp"), Some(expected.fingerprint())); + // The caller key (audit attribution + rate-limit bucket) is the + // fingerprint, so two users behind one proxy are told apart. + assert_eq!(e.get("caller"), Some(expected.caller_key().as_str())); + for (name, value) in &e.fields { + assert!( + !value.contains("nbt_supersecret"), + "audit field {name} leaked the caller's NetBox token: {value}" + ); + } + } + + #[tokio::test] + async fn passthrough_rate_limit_bucket_follows_the_token() { + // The rate limiter has two layers (unchanged by pass-through): a coarse + // pre-auth cap keyed on the peer IP, then a per-caller cap. This pins the + // second layer — the per-caller bucket is the caller's *token*, not their + // address. Each request below comes from a different peer IP so the + // coarse layer never fires and the token bucket is what's observed. + let router = router_with_passthrough( + Guard::Loopback { token: None }, + Some(Arc::new(RateLimiter::new(1).unwrap())), + AllowedHosts::default(), + Some(Placement::Authorization), + ); + let request = |token: &str, ip: &str| { + let mut req = mcp_request_with(&[("x-netbox-token", token)]); + req.extensions_mut() + .insert(ConnectInfo(SocketAddr::new(ip.parse().unwrap(), 4000))); + req + }; + + let (status, _, body) = send(router.clone(), request("nbt_alice", "10.0.0.1")).await; + assert_eq!(status, StatusCode::OK, "{body}"); + + // A different token from a different address: its own, untouched bucket. + let (status, _, body) = send(router.clone(), request("nbt_bob", "10.0.0.2")).await; + assert_eq!( + status, + StatusCode::OK, + "a second user must not inherit the first user's bucket: {body}" + ); + + // Alice again from a *third* address — throttled anyway, because the + // bucket travels with her token. Changing IP is not a way around the cap. + let (status, _, _) = send(router, request("nbt_alice", "10.0.0.3")).await; + assert_eq!( + status, + StatusCode::TOO_MANY_REQUESTS, + "the per-caller cap must key on the token, not the peer address" + ); + } + + #[test] + fn passthrough_permits_a_routable_bind() { + // Without any per-caller auth mode a routable bind is a usage error... + assert!(parse_bind_addr("0.0.0.0:8080", false).is_err()); + // ...but pass-through authenticates every caller individually, so it is + // allowed (the caller warns about TLS). + let socket = parse_bind_addr("0.0.0.0:8080", true).expect("routable bind"); + assert_eq!(socket.port(), 8080); + } } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index b7818a2..3c01ad4 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -52,6 +52,15 @@ pub struct NboxMcp { /// Per-user credential vault for write tools (Pattern 2). Absent ⇒ writes /// cannot use the shared HTTP/OIDC path. See [`vault::CredentialVault`]. vault: Option>, + /// NetBox API-token pass-through (Pattern 4): the caller's own NetBox token + /// rides on each HTTP request and is used for that request's NetBox calls. + /// When `true`, [`client`](Self::client) is only a *template* — it supplies + /// the base URL, TLS/timeout settings, and paging, while the credential + /// comes from the request. A tool call that reaches this server without a + /// token in pass-through mode is an internal error (the HTTP gate 401s + /// first), and is rejected rather than silently falling back to the shared + /// service token. + passthrough: bool, /// Transport/write-mode facts for the write tools. Local writes are allowed /// only for stdio servers with `local_writes = true`. write_mode: write::WriteMode, @@ -416,6 +425,122 @@ fn project_search_hits(report: SearchReport, fields: &[String]) -> serde_json::V value } +/// Everything one tool call needs that can differ *per request*: the NetBox +/// client to talk to, and the cache partition its results belong in. +/// +/// In the single-credential modes (stdio, loopback, static bearer, OIDC + vault) +/// this is just the server's own shared client and cache — one connection, one +/// partition, exactly as before. In NetBox API-token pass-through mode it is +/// rebuilt per request from the caller's token: +/// +/// - **client** — the template client with the caller's token swapped in +/// ([`NetBoxClient::with_token`]), so NetBox applies *that user's* object +/// permissions and records *their* name in its change log. +/// - **cache** — a partition keyed by the token's fingerprint. This is not an +/// optimization detail but a correctness requirement: NetBox filters read +/// results by permission, so a shared partition would let one user's cached +/// view of an object be served to another user who may not be allowed to see +/// it. Separate partitions make that impossible. +/// - **caller** — the fingerprint, used as the write actor so a plan issued to +/// one token can only be applied by that same token. +#[derive(Clone)] +pub(crate) struct RequestScope { + client: Arc, + cache: Cache, + /// `Some(fingerprint)` in pass-through mode; `None` otherwise. + caller: Option, +} + +impl RequestScope { + /// The client this request's NetBox calls must use. + pub(crate) fn client(&self) -> &NetBoxClient { + &self.client + } + + /// The caller's NetBox-token fingerprint, when the request carried one. + pub(crate) fn caller(&self) -> Option<&str> { + self.caller.as_deref() + } +} + +impl NboxMcp { + /// Resolve the [`RequestScope`] for one tool call. + /// + /// Fails closed: with pass-through enabled and no token on the request, this + /// errors instead of falling back to the shared service token. The HTTP gate + /// already 401s such a request, so reaching here means the transport changed + /// underneath us — silently using the service token would turn a multi-user + /// server back into a single-credential one without anyone noticing. + pub(crate) fn scope( + &self, + ctx: &RequestContext, + ) -> Result { + self.scope_from_extensions(&ctx.extensions) + } + + /// The resolution proper, over the rmcp request [`Extensions`] — unit-testable + /// without a live `RequestContext`/`Peer`, like + /// [`write_caller_from_extensions`]. + #[cfg(feature = "http")] + pub(crate) fn scope_from_extensions( + &self, + ext: &rmcp::model::Extensions, + ) -> Result { + if !self.passthrough { + return Ok(self.base_scope()); + } + let token = ext + .get::() + .and_then(|parts| parts.extensions.get::()) + .ok_or_else(|| { + ErrorData::invalid_params( + "this nbox server runs in NetBox API-token pass-through mode, but this \ + request carried no NetBox API token; send it as `X-NetBox-Token: ` \ + (or `Authorization: Bearer ` when the server has no other auth)", + None, + ) + })?; + let fingerprint = token.fingerprint().to_string(); + Ok(RequestScope { + client: Arc::new(self.client.with_token(token.as_str().to_string())), + // Per-user partition: see the type docs — cached reads must never + // cross a NetBox permission boundary. + cache: self.cache.with_partition(format!( + "{}|token:{fingerprint}", + self.profile_partition_label() + )), + caller: Some(fingerprint), + }) + } + + /// Non-`http` builds have no pass-through transport, so the scope is always + /// the shared one. + #[cfg(not(feature = "http"))] + pub(crate) fn scope_from_extensions( + &self, + _ext: &rmcp::model::Extensions, + ) -> Result { + Ok(self.base_scope()) + } + + /// The shared scope: the server's own client and cache partition. The only + /// scope in every mode except pass-through. + pub(crate) fn base_scope(&self) -> RequestScope { + RequestScope { + client: self.client.clone(), + cache: self.cache.clone(), + caller: None, + } + } + + /// A stable label for this server's connection, prefixed onto the per-token + /// cache partitions so two profiles pointed at different NetBox instances + /// can't share a partition even for the same token string. + fn profile_partition_label(&self) -> String { + crate::cache::profile_partition(&self.profile, self.client.base_url().as_str()) + } +} + /// Extract the write caller's authorization facts from the tool-call request /// context. Over the HTTP transport the validated [`oidc::Identity`] rides in /// the request [`Parts`](axum::http::request::Parts) — placed there by the auth @@ -568,7 +693,30 @@ impl NboxMcp { vault: Option, profile: String, ) -> Self { - Self::new_with_write_mode(client, cache, vault, profile, write::WriteMode::Http) + Self::new_with_write_mode(client, cache, vault, profile, write::WriteMode::Http, false) + } + + /// Build a multi-user HTTP server in NetBox API-token pass-through mode. + /// + /// `client` is a *template*: its URL/TLS/timeout/paging settings are reused, + /// but every tool call swaps in the credential the caller presented on that + /// request. There is no vault — the caller's own token both authenticates + /// them and carries their NetBox permissions, so `allow_writes` is the only + /// operator gate the write tools need. + pub fn new_passthrough( + client: NetBoxClient, + cache: Cache, + profile: String, + allow_writes: bool, + ) -> Self { + Self::new_with_write_mode( + client, + cache, + None, + profile, + write::WriteMode::Passthrough { allow_writes }, + true, + ) } /// Build a stdio server. `local_writes` is the ADR-0002 single-user mode: @@ -580,6 +728,7 @@ impl NboxMcp { None, String::new(), write::WriteMode::stdio(local_writes), + false, ) } @@ -589,11 +738,13 @@ impl NboxMcp { vault: Option, profile: String, write_mode: write::WriteMode, + passthrough: bool, ) -> Self { Self { client: Arc::new(client), cache, vault: vault.map(Arc::new), + passthrough, write_mode, profile, plans: Arc::new(Mutex::new(write::PlanStore::default())), @@ -609,18 +760,30 @@ impl NboxMcp { description = "Show NetBox connection, active backend, versions, and a token-validity preflight (the authenticated user). Use to confirm reachability and a valid token before other lookups.", annotations(read_only_hint = true) )] - async fn nbox_status(&self) -> Result, ErrorData> { - let status = self.client.status().await.map_err(to_mcp_error)?; - let api = self.client.api_routing().await; + async fn nbox_status_tool( + &self, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_status_impl(&self.scope(&ctx)?).await + } + + /// `nbox_status` proper, against one request's [`RequestScope`]. In + /// pass-through mode the reported `token` preflight is therefore the + /// *caller's* NetBox user — the natural "who am I to NetBox?" check. + async fn nbox_status_impl( + &self, + scope: &RequestScope, + ) -> Result, ErrorData> { + let client = scope.client(); + let status = client.status().await.map_err(to_mcp_error)?; + let api = client.api_routing().await; // The credential preflight is independent of the capability probe; overlap // them so `nbox_status` costs no extra serial round-trip for the token // verdict. Neither returns a `Result`, so a plain `join!` suffices. - let (capabilities, token) = tokio::join!( - self.client.capabilities(&status), - self.client.authentication_check(), - ); + let (capabilities, token) = + tokio::join!(client.capabilities(&status), client.authentication_check(),); Ok(Json(StatusReport { - netbox_url: self.client.base_url().as_str().to_string(), + netbox_url: client.base_url().as_str().to_string(), api, netbox_version: status.netbox_version, django_version: status.django_version, @@ -638,12 +801,21 @@ impl NboxMcp { output_schema = output_schema(), annotations(read_only_hint = true) )] - async fn nbox_search( + async fn nbox_search_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_search_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_search_impl( + &self, + scope: &RequestScope, + args: SearchArgs, ) -> Result, ErrorData> { let fields = args.fields; - let outcome = Box::pin(self.client.search(SearchRequest { + let outcome = Box::pin(scope.client().search(SearchRequest { query: args.query, limit: args.limit.unwrap_or(25), filters: SearchFilters { @@ -689,12 +861,24 @@ impl NboxMcp { output_schema = output_schema(), annotations(read_only_hint = true) )] - async fn nbox_get( + async fn nbox_get_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_get_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_get_impl( + &self, + scope: &RequestScope, + args: GetArgs, ) -> Result, ErrorData> { let fields = args.fields.clone(); - let Json(value) = self.get_cached(args).await.map_err(to_mcp_error)?; + let Json(value) = self + .get_cached_scoped(scope, args) + .await + .map_err(to_mcp_error)?; // `fields` projection is applied AFTER the cache (the cache stores the // full object keyed by ref/scope only), so two callers asking for // different field subsets share one fetch. @@ -713,11 +897,26 @@ impl NboxMcp { description = "Clear nbox's local read cache so the next lookups fetch fresh from NetBox. Use this after data changed in NetBox out-of-band and you need the current state before the cache TTL expires. Safe and read-only with respect to NetBox — it only drops cached copies held in this server process.", annotations(read_only_hint = true, idempotent_hint = true) )] - async fn nbox_cache_clear(&self) -> Result, ErrorData> { - self.cache.clear_all(); - Ok(Json(CacheClearReport { + async fn nbox_cache_clear_tool( + &self, + ctx: RequestContext, + ) -> Result, ErrorData> { + Ok(self.nbox_cache_clear_impl(&self.scope(&ctx)?)) + } + + /// Clearing is scoped to the caller in pass-through mode: a multi-user server + /// must not let one user drop every other user's cached reads (a trivial + /// cross-tenant denial of service). Single-credential modes keep the original + /// whole-cache semantics — there is only one user's data in there. + fn nbox_cache_clear_impl(&self, scope: &RequestScope) -> Json { + if self.passthrough { + scope.cache.clear_profile(); + } else { + self.cache.clear_all(); + } + Json(CacheClearReport { status: "cache cleared".to_string(), - })) + }) } /// Show one interface on a device, with its addresses and cable-path trace. @@ -726,14 +925,27 @@ impl NboxMcp { description = "Show one interface on a device: its config, assigned IP addresses, and the cable-path trace (what it connects to). Resolve the device by name, slug, or ID.", annotations(read_only_hint = true) )] - async fn nbox_get_interface( + async fn nbox_get_interface_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_get_interface_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_get_interface_impl( + &self, + scope: &RequestScope, + args: InterfaceArgs, ) -> Result, ErrorData> { - let view = - detail::interface_view_by_ref(&self.client, &args.device, &args.interface, ¬_found) - .await - .map_err(to_mcp_error)?; + let view = detail::interface_view_by_ref( + scope.client(), + &args.device, + &args.interface, + ¬_found, + ) + .await + .map_err(to_mcp_error)?; Ok(Json(view)) } @@ -743,17 +955,26 @@ impl NboxMcp { description = "Return the next available IP address(es) within a prefix (read-only — nothing is reserved). Pass `count` for several; `vrf` to disambiguate a prefix present in multiple VRFs.", annotations(read_only_hint = true) )] - async fn nbox_next_ip( + async fn nbox_next_ip_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_next_ip_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_next_ip_impl( + &self, + scope: &RequestScope, + args: NextIpArgs, ) -> Result, ErrorData> { let count = args.count.unwrap_or(1); let p = self - .resolve_prefix(&args.prefix, args.vrf.as_deref()) + .resolve_prefix(scope, &args.prefix, args.vrf.as_deref()) .await .map_err(to_mcp_error)?; - let available = self - .client + let available = scope + .client() .prefix_available_ips(p.id, count) .await .map_err(to_mcp_error)?; @@ -774,16 +995,25 @@ impl NboxMcp { description = "Return available (free) child prefixes within a prefix. With `length` (e.g. 26) returns the first free block of that size; without it, lists all free blocks. Pass `vrf` to disambiguate. Read-only — nothing is reserved.", annotations(read_only_hint = true) )] - async fn nbox_next_prefix( + async fn nbox_next_prefix_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_next_prefix_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_next_prefix_impl( + &self, + scope: &RequestScope, + args: NextPrefixArgs, ) -> Result, ErrorData> { let p = self - .resolve_prefix(&args.prefix, args.vrf.as_deref()) + .resolve_prefix(scope, &args.prefix, args.vrf.as_deref()) .await .map_err(to_mcp_error)?; - let free = self - .client + let free = scope + .client() .prefix_available_prefixes(p.id) .await .map_err(to_mcp_error)?; @@ -805,17 +1035,26 @@ impl NboxMcp { description = "Return recent journal entries (operator notes) for an object, newest first. `kind` and `ref` follow nbox_get; supported kinds are device, ip, prefix, vlan, site, rack, rack_group, circuit, virtual_circuit, aggregate, asn, ip_range, tenant, contact, provider, vm, vm_type, cluster, vrf, route_target, interface (as `/`).", annotations(read_only_hint = true) )] - async fn nbox_journal( + async fn nbox_journal_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_journal_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_journal_impl( + &self, + scope: &RequestScope, + args: JournalArgs, ) -> Result, ErrorData> { let limit = args.limit.unwrap_or(20); let (content_type, id) = self - .resolve_content_type_id(args.kind, &args.reference) + .resolve_content_type_id(scope, args.kind, &args.reference) .await .map_err(to_mcp_error)?; - let entries = self - .client + let entries = scope + .client() .journal_entries(content_type, id, limit) .await .map_err(to_mcp_error)?; @@ -828,18 +1067,27 @@ impl NboxMcp { description = "Return the change history (system audit log: create/update/delete, who and when) for an object, newest first. Distinct from nbox_journal (operator notes): this is the system-recorded audit trail from /api/core/object-changes/. `kind` and `ref` follow nbox_get; supported kinds are device, ip, prefix, vlan, site, rack, rack_group, circuit, virtual_circuit, aggregate, asn, ip_range, tenant, contact, provider, vm, vm_type, cluster, vrf, route_target, interface (as `/`). Each row includes the top-level fields that changed (pre vs post); pass `diff=true` (pair with a small `limit`, e.g. 1) to include the full before/after JSON payloads per row.", annotations(read_only_hint = true) )] - async fn nbox_history( + async fn nbox_history_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_history_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_history_impl( + &self, + scope: &RequestScope, + args: HistoryArgs, ) -> Result, ErrorData> { let limit = args.limit.unwrap_or(20); let diff = args.diff.unwrap_or(false); let (content_type, id) = self - .resolve_content_type_id(args.kind, &args.reference) + .resolve_content_type_id(scope, args.kind, &args.reference) .await .map_err(to_mcp_error)?; - let changes = self - .client + let changes = scope + .client() .object_changes(content_type, id, limit) .await .map_err(to_mcp_error)?; @@ -852,12 +1100,21 @@ impl NboxMcp { description = "List the tags defined in NetBox (name, slug, color, usage count). Useful for discovering valid `tag` filter values for nbox_search.", annotations(read_only_hint = true) )] - async fn nbox_list_tags( + async fn nbox_list_tags_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result, ErrorData> { + self.nbox_list_tags_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_list_tags_impl( + &self, + scope: &RequestScope, + args: ListTagsArgs, ) -> Result, ErrorData> { - let tags = self - .client + let tags = scope + .client() .tags(args.limit.unwrap_or(200)) .await .map_err(to_mcp_error)?; @@ -876,12 +1133,21 @@ impl NboxMcp { description = "List objects carrying a tag, across all kinds (NetBox 4.3+). The tag resolves by id, exact name, or exact slug. Returns each object's kind, object_type, id, display, and url, plus the resolved tag. Use this for \"what has tag X\"; use nbox_search with the `tag` filter to narrow a free-text search to tagged hits.", annotations(read_only_hint = true) )] - async fn nbox_tagged( + async fn nbox_tagged_tool( &self, Parameters(args): Parameters, + ctx: RequestContext, ) -> Result, ErrorData> { - let tag_info = self - .client + self.nbox_tagged_impl(&self.scope(&ctx)?, args).await + } + + async fn nbox_tagged_impl( + &self, + scope: &RequestScope, + args: TaggedArgs, + ) -> Result, ErrorData> { + let tag_info = scope + .client() .tag_by_ref(&args.tag) .await .map_err(to_mcp_error)? @@ -890,8 +1156,8 @@ impl NboxMcp { // the CLI's not-found (exit 4) semantics. ErrorData::invalid_params(format!("no tag matched \"{}\"", args.tag), None) })?; - let objects = self - .client + let objects = scope + .client() .tagged_objects(tag_info.id, args.limit.unwrap_or(200)) .await .map_err(to_mcp_error)?; @@ -918,7 +1184,9 @@ impl NboxMcp { Parameters(args): Parameters, ctx: RequestContext, ) -> Result, ErrorData> { - self.plan_write_impl(args, write_caller(&ctx)).await + let scope = self.scope(&ctx)?; + self.plan_write_scoped(&scope, args, write_caller(&ctx)) + .await } /// Apply a previously planned write. The submitted plan's `confirm_token` @@ -935,7 +1203,9 @@ impl NboxMcp { Parameters(args): Parameters, ctx: RequestContext, ) -> Result, ErrorData> { - self.apply_write_impl(args, write_caller(&ctx)).await + let scope = self.scope(&ctx)?; + self.apply_write_scoped(&scope, args, write_caller(&ctx)) + .await } } @@ -945,18 +1215,22 @@ impl NboxMcp { /// agent firing several reads of the same object collapses to one fetch. The /// key folds in the disambiguators so the same CIDR in two VRFs caches apart. /// A not-found/ambiguous error still propagates (nothing is cached for it). - async fn get_cached(&self, args: GetArgs) -> anyhow::Result> { - let scope = format!( + async fn get_cached_scoped( + &self, + scope: &RequestScope, + args: GetArgs, + ) -> anyhow::Result> { + let disambiguators = format!( "vrf={};site={};group={}", args.vrf.as_deref().unwrap_or(""), args.site.as_deref().unwrap_or(""), args.group.as_deref().unwrap_or(""), ); - let key = CacheKey::object(args.kind.as_str(), &args.reference, &scope); - let cached = self + let key = CacheKey::object(args.kind.as_str(), &args.reference, &disambiguators); + let cached = scope .cache .get_or_fetch(&key, || async { - let Json(value) = self.get_impl(args.clone()).await?; + let Json(value) = self.get_impl(scope, args.clone()).await?; Ok(value) }) .await?; @@ -968,8 +1242,12 @@ impl NboxMcp { /// invalid_params at the tool boundary). The fetch + view-build path is the /// same one the CLI handlers use (see [`crate::domain::detail`]); `not_found` /// supplies the MCP-flavored "use nbox_search" message. - async fn get_impl(&self, args: GetArgs) -> anyhow::Result> { - let c = &self.client; + async fn get_impl( + &self, + scope: &RequestScope, + args: GetArgs, + ) -> anyhow::Result> { + let c = scope.client(); let r = args.reference.as_str(); let value = match args.kind { GetKind::Device => { @@ -1067,17 +1345,24 @@ impl NboxMcp { /// a single text content. Disambiguators (`vrf`/`site`/`group`) have no /// place in a flat URI, so an ambiguous `ref` surfaces its candidate list as /// an `invalid_params` error — the caller can then use `nbox_get`. - async fn read_resource_impl(&self, uri: &str) -> Result { + async fn read_resource_scoped( + &self, + scope: &RequestScope, + uri: &str, + ) -> Result { let (kind, reference) = parse_resource_uri(uri)?; let Json(value) = self - .get_cached(GetArgs { - kind, - reference, - vrf: None, - site: None, - group: None, - fields: None, - }) + .get_cached_scoped( + scope, + GetArgs { + kind, + reference, + vrf: None, + site: None, + group: None, + fields: None, + }, + ) .await .map_err(to_mcp_error)?; // Pretty-print so a host that renders the resource shows readable JSON; @@ -1093,10 +1378,11 @@ impl NboxMcp { /// Resolve a CIDR to a single prefix, scoped by an optional VRF reference. async fn resolve_prefix( &self, + scope: &RequestScope, cidr: &str, vrf: Option<&str>, ) -> anyhow::Result { - detail::resolve_prefix(&self.client, cidr, vrf, ¬_found).await + detail::resolve_prefix(scope.client(), cidr, vrf, ¬_found).await } /// Resolve a ` ` to the object's dotted content type and ID, for @@ -1107,6 +1393,7 @@ impl NboxMcp { /// CLI resolver itself parses the asn ref to a `u32`. async fn resolve_content_type_id( &self, + scope: &RequestScope, kind: GetKind, value: &str, ) -> anyhow::Result<(&'static str, u64)> { @@ -1134,7 +1421,7 @@ impl NboxMcp { GetKind::Mac => "mac", GetKind::Interface => "interface", }; - crate::resolve_content_type_id(&self.client, cli_kind, value).await + crate::resolve_content_type_id(scope.client(), cli_kind, value).await } } @@ -1209,9 +1496,12 @@ impl ServerHandler for NboxMcp { async fn read_resource( &self, request: ReadResourceRequestParams, - _context: RequestContext, + context: RequestContext, ) -> Result { - self.read_resource_impl(&request.uri).await.map(Into::into) + let scope = self.scope(&context)?; + self.read_resource_scoped(&scope, &request.uri) + .await + .map(Into::into) } // Curated investigation prompts (ROADMAP "MCP prompts catalog"). The catalog @@ -1269,6 +1559,12 @@ pub mod audit; pub mod http; #[cfg(feature = "http")] pub mod oidc; +/// NetBox API-token pass-through (Pattern 4): the caller's own NetBox token +/// rides on every `/mcp` request and is forwarded verbatim to NetBox, so one +/// `nbox serve --http` instance serves many users under their own NetBox +/// identity. HTTP-only — it is a property of the transport's headers. +#[cfg(feature = "http")] +pub mod passthrough; pub mod prompts; /// Per-user credential vault (Pattern 2, DESIGN §24). Maps OIDC `sub` → /// per-user NetBox token so write tools hit NetBox under the caller's @@ -1279,5 +1575,115 @@ pub mod write; #[cfg(feature = "http")] pub use http::{OidcArgs, ServeOptions, serve_http}; +/// Test-only shims binding the *shared* [`RequestScope`] (the server's own +/// client + cache partition). +/// +/// Every tool is now a thin wrapper that resolves a per-request scope from the +/// transport and calls a `*_impl` that takes it explicitly. Unit tests drive a +/// server with no live HTTP request, so they use these shims to exercise the +/// single-credential behaviour directly; pass-through behaviour is tested by +/// building a scope explicitly (see `scope_for_token`). Keeping the shims' +/// names and signatures identical to the pre-scope API also makes the existing +/// suite a regression check that threading the scope changed no behaviour. +#[cfg(test)] +impl NboxMcp { + async fn nbox_status(&self) -> Result, ErrorData> { + self.nbox_status_impl(&self.base_scope()).await + } + + async fn nbox_search( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_search_impl(&self.base_scope(), args).await + } + + async fn nbox_get( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_get_impl(&self.base_scope(), args).await + } + + fn nbox_cache_clear(&self) -> Json { + self.nbox_cache_clear_impl(&self.base_scope()) + } + + async fn nbox_get_interface( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_get_interface_impl(&self.base_scope(), args).await + } + + async fn nbox_next_ip( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_next_ip_impl(&self.base_scope(), args).await + } + + async fn nbox_next_prefix( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_next_prefix_impl(&self.base_scope(), args).await + } + + async fn nbox_journal( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_journal_impl(&self.base_scope(), args).await + } + + async fn nbox_history( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_history_impl(&self.base_scope(), args).await + } + + async fn nbox_list_tags( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_list_tags_impl(&self.base_scope(), args).await + } + + async fn nbox_tagged( + &self, + Parameters(args): Parameters, + ) -> Result, ErrorData> { + self.nbox_tagged_impl(&self.base_scope(), args).await + } + + async fn get_cached(&self, args: GetArgs) -> anyhow::Result> { + self.get_cached_scoped(&self.base_scope(), args).await + } + + async fn read_resource_impl(&self, uri: &str) -> Result { + self.read_resource_scoped(&self.base_scope(), uri).await + } + + async fn plan_write_impl( + &self, + args: write::PlanWriteArgs, + caller: Option, + ) -> Result, ErrorData> { + self.plan_write_scoped(&self.base_scope(), args, caller) + .await + } + + async fn apply_write_impl( + &self, + args: write::ApplyWriteArgs, + caller: Option, + ) -> Result, ErrorData> { + self.apply_write_scoped(&self.base_scope(), args, caller) + .await + } +} + #[cfg(test)] mod tests; diff --git a/src/mcp/passthrough.rs b/src/mcp/passthrough.rs new file mode 100644 index 0000000..a516a52 --- /dev/null +++ b/src/mcp/passthrough.rs @@ -0,0 +1,283 @@ +//! NetBox API-token pass-through — the multi-user HTTP transport (Pattern 4). +//! +//! The other HTTP modes share **one** NetBox credential: the profile's service +//! token. Every caller therefore reads (and, with the vault, writes) as that one +//! account, and nbox's own audit log is the only per-user record. Pass-through +//! inverts that: each caller presents **their own** NetBox API token on every +//! `/mcp` request, nbox never holds a shared credential for them, and the last +//! hop to NetBox happens under the caller's identity. NetBox's own object +//! permissions become the authorization boundary, and NetBox's change log names +//! the human. +//! +//! Where the token may ride, in precedence order: +//! +//! 1. `X-NetBox-Token: ` — always accepted, and the **only** place the +//! NetBox token may ride when `Authorization` is already claimed by another +//! factor (an OIDC JWT, or the static `--http-token` bearer). +//! 2. `Authorization: Token ` — NetBox's own scheme. +//! 3. `Authorization: Bearer ` — what most MCP clients can set. +//! +//! (2) and (3) are only consulted when `Authorization` is *not* claimed; see +//! [`Placement`]. +//! +//! The raw token never leaves this module in printable form: [`PassthroughToken`] +//! has a hand-written `Debug` that renders ``, and every log/audit/ +//! rate-limit/cache-partition use goes through the non-reversible +//! [`fingerprint`](PassthroughToken::fingerprint) instead. + +use std::fmt; +use std::sync::Arc; + +use axum::http::{HeaderMap, header}; + +/// The dedicated pass-through header. Lowercase — `HeaderMap` lookups are +/// case-insensitive, but the constant is also rendered in the +/// `WWW-Authenticate` hint, so keep it readable. +pub const NETBOX_TOKEN_HEADER: &str = "x-netbox-token"; + +/// Whether the `Authorization` header is available to carry the NetBox token. +/// +/// In a plain pass-through deployment nothing else uses `Authorization`, so a +/// client can simply set a bearer and be done ([`Placement::Authorization`]). +/// When the operator layers pass-through on top of OIDC or the static bearer, +/// `Authorization` already carries *that* credential and the NetBox token must +/// use the dedicated header ([`Placement::DedicatedHeaderOnly`]) — otherwise a +/// JWT would be forwarded to NetBox as if it were an API token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Placement { + /// `X-NetBox-Token`, or `Authorization: Token|Bearer …`. + Authorization, + /// `X-NetBox-Token` only — `Authorization` belongs to another factor. + DedicatedHeaderOnly, +} + +impl Placement { + /// Resolve the placement from the two things that can claim `Authorization`. + pub fn resolve(oidc: bool, static_bearer: bool) -> Self { + if oidc || static_bearer { + Self::DedicatedHeaderOnly + } else { + Self::Authorization + } + } + + /// The caller-facing hint listing the accepted headers, used in the 401 body + /// so a misconfigured client is told exactly where to put the token. + pub fn hint(self) -> &'static str { + match self { + Self::Authorization => { + "send your NetBox API token as `Authorization: Bearer `, \ + `Authorization: Token `, or `X-NetBox-Token: `" + } + Self::DedicatedHeaderOnly => { + "send your NetBox API token as `X-NetBox-Token: ` \ + (the Authorization header carries this server's own auth)" + } + } + } +} + +/// A caller-supplied NetBox API token, plus its stable non-reversible +/// fingerprint. +/// +/// Cheap to clone (two `Arc`), because it rides in axum request extensions +/// and is cloned into a per-request [`NetBoxClient`](crate::netbox::client::NetBoxClient). +#[derive(Clone)] +pub struct PassthroughToken { + token: Arc, + fingerprint: Arc, +} + +impl fmt::Debug for PassthroughToken { + /// Never prints the token — only the fingerprint, which is safe to log. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PassthroughToken") + .field("token", &"") + .field("fingerprint", &self.fingerprint) + .finish() + } +} + +impl PassthroughToken { + /// Normalize and wrap a raw header value. Returns `None` when nothing usable + /// remains (empty, whitespace, or a bare scheme word). + /// + /// Normalization is [`crate::config::normalize_token`], the same one the CLI + /// applies to `NBOX_TOKEN` — so a client that pastes NetBox's copied + /// `Authorization` value into `X-NetBox-Token` still works. + pub fn new(raw: &str) -> Option { + let token = crate::config::normalize_token(raw)?; + let fingerprint = fingerprint(&token); + Some(Self { + token: Arc::from(token.as_str()), + fingerprint: Arc::from(fingerprint.as_str()), + }) + } + + /// The raw token, for building the NetBox `Authorization` header. The only + /// accessor that exposes the secret; call sites are the per-request client + /// construction and nothing else. + pub fn as_str(&self) -> &str { + &self.token + } + + /// A stable, non-reversible 16-hex-char label for this token. + /// + /// The same token always fingerprints to the same value, so it works as an + /// audit caller key, a rate-limit bucket, and a cache partition — while + /// being useless to an attacker who reads the log. (A SHA-256 prefix: + /// truncation weakens only preimage resistance, which is irrelevant for an + /// opaque label.) + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// The audit / rate-limit caller key. Prefixed so it can never collide with + /// a `sub:` / `client:` / `ip:` key from [`crate::mcp::audit::caller_key`]. + pub fn caller_key(&self) -> String { + format!("netbox-token:{}", self.fingerprint) + } +} + +/// SHA-256 the token and render the first 8 bytes as hex. Mirrors +/// [`crate::mcp::audit::session_hash`]'s shape so log fields look consistent. +fn fingerprint(token: &str) -> String { + use std::fmt::Write as _; + + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(token.as_bytes()); + let mut out = String::with_capacity(16); + for byte in &digest[..8] { + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// Pull the caller's NetBox token out of `headers` per `placement`. +/// +/// `None` ⇒ no usable token was presented; the gate answers `401` (the request +/// never reaches NetBox). A present-but-blank header is treated as absent — +/// forwarding an empty token would make NetBox answer 403 with a confusing +/// message instead of nbox saying "you didn't send a token". +pub fn extract(headers: &HeaderMap, placement: Placement) -> Option { + if let Some(raw) = headers + .get(NETBOX_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()) + && let Some(token) = PassthroughToken::new(raw) + { + return Some(token); + } + if placement == Placement::DedicatedHeaderOnly { + return None; + } + // `normalize_token` strips a leading `Bearer`/`Token` scheme word, so both + // Authorization spellings — and a bare value — resolve to the same token. + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(PassthroughToken::new) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.insert( + axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + axum::http::HeaderValue::from_str(value).unwrap(), + ); + } + map + } + + #[test] + fn dedicated_header_wins_and_normalizes() { + let map = headers(&[ + (NETBOX_TOKEN_HEADER, " nbt_alice "), + ("authorization", "Bearer nbt_bob"), + ]); + let token = extract(&map, Placement::Authorization).expect("token"); + assert_eq!(token.as_str(), "nbt_alice"); + } + + #[test] + fn authorization_accepts_both_schemes() { + for value in ["Bearer nbt_alice", "Token nbt_alice", "nbt_alice"] { + let map = headers(&[("authorization", value)]); + let token = extract(&map, Placement::Authorization).expect("token"); + assert_eq!(token.as_str(), "nbt_alice", "for {value}"); + } + } + + #[test] + fn authorization_ignored_when_claimed() { + let map = headers(&[("authorization", "Bearer some.jwt.value")]); + assert!( + extract(&map, Placement::DedicatedHeaderOnly).is_none(), + "a JWT in Authorization must never be forwarded to NetBox as an API token" + ); + // The dedicated header still works in the same mode. + let map = headers(&[ + ("authorization", "Bearer some.jwt.value"), + (NETBOX_TOKEN_HEADER, "nbt_alice"), + ]); + let token = extract(&map, Placement::DedicatedHeaderOnly).expect("token"); + assert_eq!(token.as_str(), "nbt_alice"); + } + + #[test] + fn blank_header_falls_through_then_yields_none() { + // A blank dedicated header must not shadow a usable Authorization value. + let map = headers(&[ + (NETBOX_TOKEN_HEADER, " "), + ("authorization", "Bearer nbt_alice"), + ]); + assert_eq!( + extract(&map, Placement::Authorization).map(|t| t.as_str().to_string()), + Some("nbt_alice".to_string()) + ); + // Nothing usable anywhere ⇒ None (the gate turns this into a 401). + let map = headers(&[(NETBOX_TOKEN_HEADER, "Bearer ")]); + assert!(extract(&map, Placement::Authorization).is_none()); + assert!(extract(&HeaderMap::new(), Placement::Authorization).is_none()); + } + + #[test] + fn fingerprint_is_stable_distinct_and_not_the_token() { + let alice = PassthroughToken::new("nbt_alice").unwrap(); + let alice2 = PassthroughToken::new("nbt_alice").unwrap(); + let bob = PassthroughToken::new("nbt_bob").unwrap(); + assert_eq!(alice.fingerprint(), alice2.fingerprint()); + assert_ne!(alice.fingerprint(), bob.fingerprint()); + assert_eq!(alice.fingerprint().len(), 16); + assert!(!alice.fingerprint().contains("nbt_alice")); + assert_eq!( + alice.caller_key(), + format!("netbox-token:{}", alice.fingerprint()) + ); + } + + #[test] + fn debug_never_prints_the_token() { + let token = PassthroughToken::new("nbt_supersecret").unwrap(); + let rendered = format!("{token:?}"); + assert!(!rendered.contains("nbt_supersecret"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + } + + #[test] + fn placement_resolution() { + assert_eq!(Placement::resolve(false, false), Placement::Authorization); + assert_eq!( + Placement::resolve(true, false), + Placement::DedicatedHeaderOnly + ); + assert_eq!( + Placement::resolve(false, true), + Placement::DedicatedHeaderOnly + ); + } +} diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index d715783..10120ab 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -286,7 +286,7 @@ async fn cache_clear_busts_resource_cache_entries() { .expect("first resource read fills cache"); assert_eq!(one_text(&first, "nbox://site/iad1")["name"], json!("IAD1")); - server.nbox_cache_clear().await.expect("cache clear"); + server.nbox_cache_clear(); let second = server .read_resource_impl("nbox://site/iad1") @@ -2782,10 +2782,7 @@ mod contracts { #[tokio::test] async fn cache_clear_report_shape_is_pinned() { let mock = MockServer::start().await; - let Json(report) = server_for(&mock) - .nbox_cache_clear() - .await - .expect("cache clear"); + let Json(report) = server_for(&mock).nbox_cache_clear(); let value = serde_json::to_value(&report).expect("serialize report"); assert_keys(&value, &["status"]); @@ -4704,3 +4701,229 @@ async fn apply_write_tag_on_prefix_routes_to_tag_applier() { assert_eq!(receipt.target.kind, "prefix"); assert_eq!(receipt.status, 200); } + +// --------------------------------------------------------------------------- +// NetBox API-token pass-through (multi-user mode) +// --------------------------------------------------------------------------- + +/// The multi-user server: the profile supplies the URL only — there is no +/// service token, because in pass-through mode every tool call is authenticated +/// by the credential the *caller* presented. +fn passthrough_server_for(mock: &MockServer, allow_writes: bool) -> NboxMcp { + let profile = ProfileConfig { + url: mock.uri(), + ..Default::default() + }; + NboxMcp::new_passthrough( + NetBoxClient::new(&profile, None).unwrap(), + crate::cache::Cache::disabled(), + "prod".into(), + allow_writes, + ) +} + +/// Build the rmcp request extensions a Streamable-HTTP tool call carries, +/// optionally with the caller's NetBox token in them — exactly the handoff +/// `http::gate_inner` performs (it inserts the token into the axum request's +/// extensions; rmcp then stores the request `Parts` in the tool context). +fn request_extensions(netbox_token: Option<&str>) -> rmcp::model::Extensions { + let mut request = axum::http::Request::builder().uri("/mcp").body(()).unwrap(); + if let Some(raw) = netbox_token { + let token = crate::mcp::passthrough::PassthroughToken::new(raw).expect("a usable token"); + request.extensions_mut().insert(token); + } + let (parts, ()) = request.into_parts(); + let mut ext = rmcp::model::Extensions::new(); + ext.insert(parts); + ext +} + +#[tokio::test] +async fn passthrough_calls_netbox_with_the_callers_own_token() { + let mock = MockServer::start().await; + // The mock only answers when the request carries Alice's credential, so a + // server that fell back to the (absent) service token cannot pass this. + Mock::given(method("GET")) + .and(path("/api/dcim/sites/")) + .and(header("Authorization", "Token nbt_alice")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "count": 1, "next": null, "previous": null, + "results": [{ + "id": 1, "url": "http://nb/api/dcim/sites/1/", + "name": "IAD1", "slug": "iad1" + }] + }))) + .mount(&mock) + .await; + mount_empty(&mock, "/api/dcim/racks/").await; + mount_empty(&mock, "/api/dcim/devices/").await; + mount_empty(&mock, "/api/ipam/prefixes/").await; + mount_empty(&mock, "/api/ipam/vlans/").await; + + let server = passthrough_server_for(&mock, false); + let scope = server + .scope_from_extensions(&request_extensions(Some("nbt_alice"))) + .expect("a scope for Alice's token"); + + let Json(value) = server + .nbox_get_impl(&scope, get_args(GetKind::Site, "iad1")) + .await + .expect("the site, fetched under the caller's own token"); + assert_eq!(value["name"], json!("IAD1")); +} + +#[tokio::test] +async fn passthrough_without_a_token_fails_closed() { + let mock = MockServer::start().await; + let server = passthrough_server_for(&mock, false); + + let err = server + .scope_from_extensions(&request_extensions(None)) + .err() + .expect("a pass-through server must never fall back to the service token"); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert!( + err.message.contains("X-NetBox-Token"), + "the error should say where the token belongs: {}", + err.message + ); + // And no request was made to NetBox at all. + assert!(mock.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn passthrough_partitions_the_read_cache_per_caller() { + // NetBox filters reads by permission, so one user's cached view must never be + // served to another. Point the client at a dead address: the only way a read + // can succeed here is a cache hit, which makes the isolation observable. + let profile = ProfileConfig { + url: "http://127.0.0.1:9/".into(), + ..Default::default() + }; + let server = NboxMcp::new_passthrough( + NetBoxClient::new(&profile, None).unwrap(), + crate::cache::Cache::from_settings( + "ignored".into(), + &crate::config::CacheSettings { + enabled: true, + ttl_secs: 30, + }, + ), + "prod".into(), + false, + ); + + let alice = server + .scope_from_extensions(&request_extensions(Some("nbt_alice"))) + .unwrap(); + let bob = server + .scope_from_extensions(&request_extensions(Some("nbt_bob"))) + .unwrap(); + + // Seed a value into Alice's partition only. + let key = crate::cache::CacheKey::object("site", "iad1", "vrf=;site=;group="); + alice + .cache + .put(&key, &json!({ "name": "iad1", "secret_to_alice": true })); + + let args = get_args(GetKind::Site, "iad1"); + let Json(value) = server + .get_cached_scoped(&alice, args.clone()) + .await + .expect("Alice sees her own cached copy"); + assert_eq!(value["secret_to_alice"], json!(true)); + + assert!( + server.get_cached_scoped(&bob, args).await.is_err(), + "Bob must miss Alice's partition and fall through to NetBox (unreachable here)" + ); +} + +#[tokio::test] +async fn passthrough_cache_clear_only_drops_the_callers_partition() { + let profile = ProfileConfig { + url: "http://127.0.0.1:9/".into(), + ..Default::default() + }; + let server = NboxMcp::new_passthrough( + NetBoxClient::new(&profile, None).unwrap(), + crate::cache::Cache::from_settings( + "ignored".into(), + &crate::config::CacheSettings { + enabled: true, + ttl_secs: 30, + }, + ), + "prod".into(), + false, + ); + let alice = server + .scope_from_extensions(&request_extensions(Some("nbt_alice"))) + .unwrap(); + let bob = server + .scope_from_extensions(&request_extensions(Some("nbt_bob"))) + .unwrap(); + + let key = crate::cache::CacheKey::object("site", "iad1", "vrf=;site=;group="); + alice.cache.put(&key, &json!({ "owner": "alice" })); + bob.cache.put(&key, &json!({ "owner": "bob" })); + + // Bob clears "the cache" — Alice's entries must survive, or any caller could + // evict every other caller's reads at will. + server.nbox_cache_clear_impl(&bob); + + let Json(value) = server + .get_cached_scoped(&alice, get_args(GetKind::Site, "iad1")) + .await + .expect("Alice's cached copy survived Bob's clear"); + assert_eq!(value["owner"], json!("alice")); + assert!( + server + .get_cached_scoped(&bob, get_args(GetKind::Site, "iad1")) + .await + .is_err(), + "Bob's own partition was cleared" + ); +} + +#[tokio::test] +async fn passthrough_writes_are_gated_on_allow_writes() { + let mock = MockServer::start().await; + let server = passthrough_server_for(&mock, false); + let scope = server + .scope_from_extensions(&request_extensions(Some("nbt_alice"))) + .unwrap(); + + let args = crate::mcp::write::PlanWriteArgs { + operation: crate::mcp::write::WriteOperation::DeviceStatus { + device: "edge01".into(), + status: "active".into(), + }, + }; + let err = server + .plan_write_scoped(&scope, args, None) + .await + .err() + .expect("writes are off by default, even with a valid caller token"); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS); + assert!(err.message.contains("allow-writes"), "{}", err.message); + assert!( + mock.received_requests().await.unwrap().is_empty(), + "a rejected write must not touch NetBox" + ); +} + +#[tokio::test] +async fn a_non_passthrough_server_ignores_a_caller_token() { + // Defense in depth: a caller must not be able to switch a single-credential + // server into pass-through by sending the header anyway. + let mock = MockServer::start().await; + let server = server_for(&mock); + let scope = server + .scope_from_extensions(&request_extensions(Some("nbt_alice"))) + .expect("the shared scope"); + assert!( + scope.caller().is_none(), + "pass-through must be a server-side mode, never something a client can turn on" + ); +} diff --git a/src/mcp/write.rs b/src/mcp/write.rs index 2d1124a..77b3d3f 100644 --- a/src/mcp/write.rs +++ b/src/mcp/write.rs @@ -58,7 +58,17 @@ pub(crate) enum ApplierKind { /// principal. The key is internal to the plan store, not exposed as a credential. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum WriteActor { - Oidc { sub: String }, + Oidc { + sub: String, + }, + /// A NetBox API-token pass-through caller, identified by their token's + /// fingerprint. The credential itself *is* the identity here — NetBox + /// authenticates and authorizes it, and records the matching user in its own + /// change log — so a plan issued to one token can only ever be applied by a + /// request carrying that same token. + NetBoxToken { + fingerprint: String, + }, Local, } @@ -66,6 +76,7 @@ impl WriteActor { pub(crate) fn key(&self) -> String { match self { WriteActor::Oidc { sub } => format!("sub:{sub}"), + WriteActor::NetBoxToken { fingerprint } => format!("netbox-token:{fingerprint}"), WriteActor::Local => "local".to_string(), } } @@ -77,7 +88,16 @@ impl WriteActor { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum WriteMode { Http, - Stdio { local_writes: bool }, + /// HTTP with NetBox API-token pass-through: the caller's own NetBox token + /// authorizes the write, so there is no vault and no OIDC scope to check — + /// `allow_writes` is the operator's single on/off gate, and NetBox's own + /// permissions decide what the caller may actually change. + Passthrough { + allow_writes: bool, + }, + Stdio { + local_writes: bool, + }, } impl WriteMode { @@ -326,8 +346,31 @@ impl NboxMcp { /// The profile token is used for writes only in explicit stdio local mode. fn write_client( &self, + scope: &super::RequestScope, caller: Option, ) -> Result<(NetBoxClient, WriteActor), ErrorData> { + // Pass-through first: when the request carried the caller's own NetBox + // token, that token is both the identity and the authorization, and the + // scope's client already holds it. No vault lookup, no scope check — the + // write lands in NetBox as the caller, and NetBox refuses it if they lack + // the permission. + if let Some(fingerprint) = scope.caller() { + return match self.write_mode { + WriteMode::Passthrough { allow_writes: true } => Ok(( + scope.client().clone(), + WriteActor::NetBoxToken { + fingerprint: fingerprint.to_string(), + }, + )), + _ => Err(ErrorData::invalid_params( + "MCP writes are not enabled on this nbox serve instance; \ + set [serve].allow_writes = true or pass --allow-writes to let \ + NetBox API-token pass-through callers write under their own token", + None, + )), + }; + } + if let Some(caller) = caller { let vault = self.vault.as_ref().ok_or_else(|| { ErrorData::invalid_params( @@ -380,12 +423,13 @@ impl NboxMcp { } /// Plan a write operation. Builds a `MutationPlan` without mutating. - pub(crate) async fn plan_write_impl( + pub(crate) async fn plan_write_scoped( &self, + scope: &super::RequestScope, args: PlanWriteArgs, caller: Option, ) -> Result, ErrorData> { - let (client, actor) = self.write_client(caller)?; + let (client, actor) = self.write_client(scope, caller)?; let profile = self.profile.as_str(); let (applier, plan_result) = match args.operation { WriteOperation::InterfaceDescription { @@ -522,12 +566,13 @@ impl NboxMcp { /// the submitted `confirm_token` (bound to the same caller) and applies that /// stored plan — the caller-supplied plan contents are never trusted, since /// the token is a non-secret hash a write-scoped caller could forge. - pub(crate) async fn apply_write_impl( + pub(crate) async fn apply_write_scoped( &self, + scope: &super::RequestScope, args: ApplyWriteArgs, caller: Option, ) -> Result, ErrorData> { - let (client, actor) = self.write_client(caller)?; + let (client, actor) = self.write_client(scope, caller)?; // Apply the plan this server issued for the token — never the caller's // contents. A forged/tampered plan has no matching server-stored entry. diff --git a/tests/mcp_serve_http_tests.rs b/tests/mcp_serve_http_tests.rs index c719720..19e7c6b 100644 --- a/tests/mcp_serve_http_tests.rs +++ b/tests/mcp_serve_http_tests.rs @@ -840,3 +840,294 @@ async fn oidc_write_rejected_for_unmapped_sub() { "no NetBox call on a vault miss" ); } + +// --------------------------------------------------------------------------- +// Pattern 4: NetBox API-token pass-through (multi-user) +// --------------------------------------------------------------------------- + +/// Spawn `nbox serve --http --netbox-token-passthrough` against `netbox_url`. +/// No `token_env`/`NBOX_TOKEN` service credential is configured that NetBox +/// would accept: every request must be authenticated by the caller's own token +/// or not at all. +fn spawn_passthrough(netbox_url: &str) -> HttpServer { + let port = free_port(); + let addr = format!("127.0.0.1:{port}"); + let mut config = NamedTempFile::new().expect("create temp config"); + write!( + config, + "active_profile = \"test\"\n\ + \n\ + [profiles.test]\n\ + url = \"{netbox_url}\"\n\ + token_env = \"NBOX_TOKEN\"\n\ + \n\ + [serve]\n\ + netbox_token_passthrough = true\n", + ) + .expect("write temp config"); + config.flush().expect("flush temp config"); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_nbox")); + cmd.arg("--config") + .arg(config.path()) + .arg("serve") + .arg("--http") + .arg(&addr) + .arg("--allowed-host") + .arg("127.0.0.1") + .env("NBOX_TOKEN", "service-token-that-netbox-rejects") + .env_remove("NBOX_SERVE_TOKEN") + .env_remove("NBOX_LOG") + .env_remove("RUST_LOG") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = cmd.spawn().expect("spawn nbox serve --http (pass-through)"); + let server = HttpServer { + child, + base: format!("http://{addr}"), + _config: config, + }; + server.wait_until_ready(); + server +} + +/// Mount `/api/dcim/sites/` so each caller's token yields a *different* site. +/// The site name is therefore a direct readout of which credential reached +/// NetBox, which is what makes the isolation assertions meaningful. +async fn mount_site_per_token(netbox: &MockServer, pairs: &[(&str, &str)]) { + for (token, site) in pairs { + let auth = format!("Token {token}"); + Mock::given(method("GET")) + .and(path("/api/dcim/sites/")) + .and(header("authorization", auth.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "count": 1, "next": null, "previous": null, + "results": [{ + "id": 1, + "url": format!("{}/api/dcim/sites/1/", netbox.uri()), + "name": site, + "slug": "shared", + "status": { "value": "active", "label": "Active" }, + }] + }))) + .mount(netbox) + .await; + } + // Anything else (notably the service token) is rejected the way NetBox would. + Mock::given(method("GET")) + .and(path("/api/dcim/sites/")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "detail": "Invalid token." + }))) + .mount(netbox) + .await; + for p in [ + "/api/dcim/racks/", + "/api/dcim/devices/", + "/api/ipam/prefixes/", + "/api/ipam/vlans/", + ] { + Mock::given(method("GET")) + .and(path(p)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "count": 0, "next": null, "previous": null, "results": [] + }))) + .mount(netbox) + .await; + } +} + +/// POST to `/mcp` with the caller's NetBox token in the dedicated header. +async fn post_as( + client: &reqwest::Client, + url: &str, + body: &Value, + session: Option<&str>, + netbox_token: &str, +) -> reqwest::Response { + let mut req = client + .post(url) + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("x-netbox-token", netbox_token) + .json(body); + if let Some(sid) = session { + req = req.header("mcp-session-id", sid); + } + req.send().await.expect("send POST /mcp") +} + +/// Handshake as a pass-through caller, returning the session id. +async fn handshake_as(client: &reqwest::Client, url: &str, netbox_token: &str) -> String { + let init = post_as( + client, + url, + &json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "nbox-passthrough-e2e", "version": "0.0.0" } + } + }), + None, + netbox_token, + ) + .await; + assert_eq!(init.status(), StatusCode::OK, "initialize should be 200"); + let session = init + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .expect("initialize must return an Mcp-Session-Id") + .to_string(); + let _ = read_sse_for_id(init, 1).await; + let ack = post_as( + client, + url, + &json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + Some(&session), + netbox_token, + ) + .await; + assert_eq!(ack.status(), StatusCode::ACCEPTED); + session +} + +/// Call a tool as a pass-through caller. +async fn call_tool_as( + client: &reqwest::Client, + url: &str, + session: &str, + netbox_token: &str, + id: i64, + name: &str, + arguments: Value, +) -> Value { + let resp = post_as( + client, + url, + &json!({ + "jsonrpc": "2.0", "id": id, "method": "tools/call", + "params": { "name": name, "arguments": arguments }, + }), + Some(session), + netbox_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK, "tools/call {name} status"); + read_sse_for_id(resp, id).await +} + +#[tokio::test] +async fn passthrough_serves_two_users_under_their_own_netbox_tokens() { + let netbox = MockServer::start().await; + mount_site_per_token( + &netbox, + &[("tok-alice", "ALICE-SITE"), ("tok-bob", "BOB-SITE")], + ) + .await; + let server = spawn_passthrough(&netbox.uri()); + let client = reqwest::Client::new(); + let url = server.mcp_url(); + + // Two independent sessions on one server process. + let alice = handshake_as(&client, &url, "tok-alice").await; + let bob = handshake_as(&client, &url, "tok-bob").await; + + let args = json!({ "kind": "site", "ref": "shared" }); + let a = call_tool_as( + &client, + &url, + &alice, + "tok-alice", + 2, + "nbox_get", + args.clone(), + ) + .await; + assert_eq!(tool_payload(&a)["name"], json!("ALICE-SITE"), "{a}"); + + let b = call_tool_as(&client, &url, &bob, "tok-bob", 3, "nbox_get", args.clone()).await; + assert_eq!( + tool_payload(&b)["name"], + json!("BOB-SITE"), + "Bob must see his own NetBox view, not Alice's: {b}" + ); + + // Back to Alice on her existing session: the token is read per request, so a + // long-lived session keeps resolving to the right caller. + let a2 = call_tool_as(&client, &url, &alice, "tok-alice", 4, "nbox_get", args).await; + assert_eq!(tool_payload(&a2)["name"], json!("ALICE-SITE"), "{a2}"); +} + +#[tokio::test] +async fn passthrough_rejects_a_request_without_a_caller_token() { + let netbox = MockServer::start().await; + mount_site_per_token(&netbox, &[("tok-alice", "ALICE-SITE")]).await; + let server = spawn_passthrough(&netbox.uri()); + let client = reqwest::Client::new(); + + // No `X-NetBox-Token`: refused at the gate, before any MCP handling — and + // emphatically *not* silently downgraded to the server's service token. + let resp = post( + &client, + &server.mcp_url(), + &json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "anon", "version": "0.0.0" } + } + }), + None, + None, + ) + .await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = resp.text().await.unwrap_or_default(); + assert!( + body.to_lowercase().contains("netbox"), + "the 401 should name the missing credential: {body}" + ); + assert!( + netbox.received_requests().await.unwrap().is_empty(), + "an unauthenticated caller must never reach NetBox" + ); +} + +#[test] +fn passthrough_is_rejected_on_stdio() { + // stdio has no per-request headers, so there is no caller token to forward. + // Fail loudly at startup rather than quietly keeping the service token. + let mut config = NamedTempFile::new().expect("create temp config"); + write!( + config, + "active_profile = \"test\"\n\ + \n\ + [profiles.test]\n\ + url = \"http://127.0.0.1:1/\"\n\ + token_env = \"NBOX_TOKEN\"\n" + ) + .expect("write temp config"); + config.flush().expect("flush temp config"); + + let out = Command::new(env!("CARGO_BIN_EXE_nbox")) + .arg("--config") + .arg(config.path()) + .arg("serve") + .arg("--netbox-token-passthrough") + .env("NBOX_TOKEN", "dummy") + .env_remove("NBOX_SERVE_TOKEN") + .stdin(Stdio::null()) + .output() + .expect("run nbox serve"); + assert_eq!(out.status.code(), Some(2), "usage errors exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--http"), + "the error should point at the HTTP transport: {stderr}" + ); +}