Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Service-principal auth-proxy sidecar.

Runs inside a deployed workload's pod as a loopback forwarder. A co-located
workload whose HTTP client we do not control (e.g. a NAT agent calling the
Inference Gateway) points its platform base URL at this proxy
(``http://127.0.0.1:<port>``) and sends no credentials of its own. The proxy
stamps a service-principal identity header (``X-NMP-Principal-Id: service:<name>``)
on every forwarded request, which the platform authorizes via the ServiceSystem
role. This is the same static service-identity the platform's own SDK clients
use (``get_platform_sdk(as_service=...)``); the proxy exists only for workloads
that cannot set the header themselves.

Started via ``nemo services run --sidecars auth-proxy``.
"""

from __future__ import annotations

import logging
import os
import threading
from collections.abc import AsyncIterator

import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask

logger = logging.getLogger(__name__)

# Loopback host + port the proxy listens on. The workload targets this address.
AUTH_PROXY_HOST_ENVVAR = "NMP_AUTH_PROXY_HOST"
AUTH_PROXY_PORT_ENVVAR = "NMP_AUTH_PROXY_PORT"
# Service-principal name stamped on forwarded requests (e.g. "agents").
AUTH_PROXY_PRINCIPAL_ENVVAR = "NMP_AUTH_PROXY_PRINCIPAL"
DEFAULT_AUTH_PROXY_HOST = "127.0.0.1"
DEFAULT_AUTH_PROXY_PORT = 8090
DEFAULT_AUTH_PROXY_PRINCIPAL = "agents"

_READ_TIMEOUT_ENVVAR = "NMP_AUTH_PROXY_READ_TIMEOUT"
_PRINCIPAL_ID_HEADER = "x-nmp-principal-id"

# Hop-by-hop headers must not be forwarded (RFC 7230). We also drop the
# workload's own authorization and principal headers (we set the principal), and
# host/content-length (rewritten by httpx / chunked transfer).
_STRIP_REQUEST_HEADERS = frozenset(
{
"host",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"authorization",
"content-length",
_PRINCIPAL_ID_HEADER,
"x-nmp-principal-on-behalf-of",
}
)
_STRIP_RESPONSE_HEADERS = frozenset(
{
"connection",
"keep-alive",
"transfer-encoding",
"content-length",
}
)


def _upstream_base_url() -> str:
"""Return the platform base URL to forward to (env override or platform config)."""
from nemo_platform_plugin.config import get_platform_config

return (os.environ.get("NEMO_BASE_URL") or os.environ.get("NMP_BASE_URL") or get_platform_config().base_url).rstrip(
"/"
)


def build_app(*, base_url: str, principal: str) -> FastAPI:
"""Build the forwarding FastAPI app for the given upstream and service principal."""
app = FastAPI(title="nmp-auth-proxy")
principal_id = principal if principal.startswith("service:") else f"service:{principal}"
read_timeout = float(os.environ.get(_READ_TIMEOUT_ENVVAR, "300"))
timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=60.0, pool=10.0)
client = httpx.AsyncClient(base_url=base_url, timeout=timeout, follow_redirects=False)

@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}

@app.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
)
async def forward(request: Request, path: str) -> StreamingResponse:
headers = {k: v for k, v in request.headers.items() if k.lower() not in _STRIP_REQUEST_HEADERS}
headers[_PRINCIPAL_ID_HEADER] = principal_id
url = httpx.URL(path="/" + path, query=request.url.query.encode("utf-8"))
body = await request.body()
upstream = client.build_request(request.method, url, headers=headers, content=body)
response = await client.send(upstream, stream=True)

async def _body() -> AsyncIterator[bytes]:
try:
async for chunk in response.aiter_raw():
yield chunk
finally:
await response.aclose()

resp_headers = {k: v for k, v in response.headers.items() if k.lower() not in _STRIP_RESPONSE_HEADERS}
return StreamingResponse(
_body(),
status_code=response.status_code,
headers=resp_headers,
background=BackgroundTask(response.aclose),
)

return app


def run(parent_stop_signal: threading.Event | None = None) -> None:
"""Sidecar entrypoint. Serves the loopback auth-proxy until stopped."""
base_url = _upstream_base_url()
principal = os.environ.get(AUTH_PROXY_PRINCIPAL_ENVVAR, DEFAULT_AUTH_PROXY_PRINCIPAL)
host = os.environ.get(AUTH_PROXY_HOST_ENVVAR, DEFAULT_AUTH_PROXY_HOST)
port = int(os.environ.get(AUTH_PROXY_PORT_ENVVAR, str(DEFAULT_AUTH_PROXY_PORT)))
app = build_app(base_url=base_url, principal=principal)

config = uvicorn.Config(app, host=host, port=port, log_level="info", access_log=False)
server = uvicorn.Server(config)

logger.info("Starting auth-proxy sidecar on %s:%s -> %s (principal=service:%s)", host, port, base_url, principal)
if parent_stop_signal is None:
server.run()
return

thread = threading.Thread(target=server.run, name="auth-proxy-uvicorn", daemon=True)
thread.start()
try:
while not parent_stop_signal.is_set():
parent_stop_signal.wait(timeout=1)
finally:
server.should_exit = True
thread.join(timeout=10)
logger.info("auth-proxy sidecar stopped")


if __name__ == "__main__":
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
run()
65 changes: 65 additions & 0 deletions packages/nmp_common/tests/auth/test_workload_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Tests for the service-principal auth-proxy sidecar forwarder."""

from __future__ import annotations

import httpx
import respx
from fastapi.testclient import TestClient
from nmp.common.auth.workload_proxy.main import build_app


@respx.mock
def test_forward_stamps_service_principal_and_preserves_path() -> None:
upstream = "http://nemo-platform-api:8080"
route = respx.post(f"{upstream}/apis/inference-gateway/v2/workspaces/default/openai/-/v1/chat/completions").mock(
return_value=httpx.Response(200, json={"ok": True})
)
app = build_app(base_url=upstream, principal="agents")
client = TestClient(app)

resp = client.post(
"/apis/inference-gateway/v2/workspaces/default/openai/-/v1/chat/completions",
json={"model": "m", "messages": []},
headers={"authorization": "Bearer not-used"},
)

assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert route.called
sent = route.calls.last.request
# The proxy sets the service-principal identity and drops the placeholder auth.
assert sent.headers["x-nmp-principal-id"] == "service:agents"
assert "authorization" not in {k.lower() for k in sent.headers}


@respx.mock
def test_forward_normalizes_bare_principal_name() -> None:
upstream = "http://nemo-platform-api:8080"
route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={}))
# Already-prefixed principal is passed through unchanged.
app = build_app(base_url=upstream, principal="service:models")
client = TestClient(app)
client.get("/apis/entities/v2/workspaces")
assert route.calls.last.request.headers["x-nmp-principal-id"] == "service:models"


@respx.mock
def test_forward_passes_through_upstream_status() -> None:
upstream = "http://nemo-platform-api:8080"
respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(403, json={"detail": "no"}))
app = build_app(base_url=upstream, principal="agents")
client = TestClient(app)

resp = client.get("/apis/entities/v2/workspaces")
assert resp.status_code == 403


def test_healthz_does_not_require_upstream() -> None:
app = build_app(base_url="http://nemo-platform-api:8080", principal="agents")
client = TestClient(app)
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

AVAILABLE_SIDECARS: dict[str, str] = {
"adapters": "nmp.core.models.sidecars.adapters.main:run",
"auth-proxy": "nmp.common.auth.workload_proxy.main:run",
}

SERVICE_SIDECAR_DEPENDENCIES: dict[str, set[str]] = {
Expand Down
19 changes: 19 additions & 0 deletions plugins/nemo-agents/src/nemo_agents_plugin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@ class DeploymentsRunnerConfig(BaseModel):
"via a ConfigMap subPath. Matches the image's NAT_CONFIG_FILE convention."
),
)
auth_proxy_image_name: str = Field(
default="nmp-api",
description=(
"Image name for the auth-proxy sidecar injected when platform auth is enabled. Qualified "
"with the platform image registry/tag. Must be an nmp-api image (runs "
"`nemo services run --sidecars auth-proxy`)."
),
)
auth_proxy_image: str = Field(
default="",
description=(
"Optional fully-qualified image override for the auth-proxy sidecar. When set, used "
"verbatim instead of qualifying auth_proxy_image_name with the platform registry/tag."
),
)
auth_proxy_port: int = Field(
default=8090,
description="Loopback port the auth-proxy sidecar listens on; deployed agents target it as their inference base URL.",
)


class AgentsConfig(NemoConfig):
Expand Down
Loading
Loading