From 1d2db55e5c0685bc0ff7942b2052b4e9688c50f6 Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Thu, 30 Jul 2026 14:07:09 -0600 Subject: [PATCH 1/2] fix(agents): scope agent deployments to creator via on-behalf-of Agent deployments previously authenticated as the service:agents principal, which resolves to the ServiceSystem role (platform-wide wildcard access). Scope a deployment's runtime platform access to the identity that created it by delegating via on-behalf-of, instead of granting admin-level reach. - workload-proxy sidecar: add optional NMP_AUTH_PROXY_ON_BEHALF_OF; stamp X-NMP-Principal-On-Behalf-Of on forwarded requests, and strip the inbound OBO header so a co-located workload cannot spoof the delegated identity. - deployments plugin: add DeploymentConfig.auth_proxy_sidecar_on_behalf_of and wire it into the auth-proxy sidecar env (regenerated plugin OpenAPI spec). - agents runner: thread the deployment creator (created_by) through the container backend into the sidecar OBO env at deploy time. - inference-gateway: enforce delegated workspace access on the proxy path. For a delegated service principal, verify the on-behalf-of user holds the required inference permission in the target workspace (via the PDP evaluated as the delegated user). Non-delegated callers are unchanged, preserving the existing internal service bypass. Without this, the IGW route gate takes the service bypass and never narrows on OBO. The control plane (controller reconcile + DeploymentConfig/Deployment entity writes) intentionally remains service:agents. Adds unit + integration coverage for the sidecar OBO stamping/spoof-stripping, the DeploymentConfig wiring, the created_by threading, and IGW delegated allow/deny behavior. Signed-off-by: Ben McCown --- .../nmp/common/auth/workload_proxy/main.py | 43 ++++++-- .../tests/auth/test_workload_proxy.py | 66 ++++++++++++ .../src/nemo_agents_plugin/runner/backend.py | 10 +- .../nemo_agents_plugin/runner/controller.py | 1 + .../runner/deployments_backend.py | 18 ++++ .../nemo_agents_plugin/runner/in_memory.py | 6 +- .../tests/unit/test_runner_deployments.py | 36 ++++++- plugins/nemo-deployments/openapi/openapi.yaml | 9 ++ .../src/nemo_deployments_plugin/auth_proxy.py | 18 ++-- .../src/nemo_deployments_plugin/entities.py | 11 ++ .../tests/unit/test_auth_proxy.py | 31 ++++++ .../nmp/core/inference_gateway/api/authz.py | 101 ++++++++++++++++++ .../core/inference_gateway/api/v2/models.py | 11 +- .../core/inference_gateway/api/v2/openai.py | 13 ++- .../inference_gateway/api/v2/providers.py | 13 ++- .../tests/integration/test_igw_with_auth.py | 99 +++++++++++++++++ .../tests/unit/test_authz.py | 87 +++++++++++++++ 17 files changed, 555 insertions(+), 18 deletions(-) create mode 100644 services/core/inference-gateway/src/nmp/core/inference_gateway/api/authz.py create mode 100644 services/core/inference-gateway/tests/unit/test_authz.py diff --git a/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py b/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py index 6d05a0f067..174579d5fb 100644 --- a/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py +++ b/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py @@ -13,6 +13,15 @@ use (``get_platform_sdk(as_service=...)``); the proxy exists only for workloads that cannot set the header themselves. +When ``NMP_AUTH_PROXY_ON_BEHALF_OF`` is set, the proxy additionally stamps +``X-NMP-Principal-On-Behalf-Of`` so the platform authorizes the request as that +delegated principal rather than granting the service principal's full +(ServiceSystem) reach. This scopes a deployed workload's platform access to the +identity that created it (e.g. an agent deployment acting as its creator). The +delegated identity is baked in at deploy time and is *not* taken from the +incoming request — the inbound principal/OBO headers are stripped so a co-located +workload cannot spoof a different identity. + Started via ``nemo services run --sidecars auth-proxy``. """ @@ -36,15 +45,20 @@ 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" +# Optional principal id to delegate to via on-behalf-of (e.g. the workload's +# creator). When set, the service principal acts on behalf of this identity so +# the platform scopes access to what that principal can reach. +AUTH_PROXY_ON_BEHALF_OF_ENVVAR = "NMP_AUTH_PROXY_ON_BEHALF_OF" DEFAULT_AUTH_PROXY_HOST = "127.0.0.1" DEFAULT_AUTH_PROXY_PORT = 8090 _READ_TIMEOUT_ENVVAR = "NMP_AUTH_PROXY_READ_TIMEOUT" _PRINCIPAL_ID_HEADER = "x-nmp-principal-id" +_ON_BEHALF_OF_HEADER = "x-nmp-principal-on-behalf-of" # Minimal request-header sanitization. We only drop what would be actively wrong: -# - the workload's own credential / principal header (we set the identity), so it -# can't be spoofed or conflict with what we stamp; +# - the workload's own credential / principal / on-behalf-of headers (we set the +# identity), so they can't be spoofed or conflict with what we stamp; # - host and content-length, which httpx recomputes for the upstream request # (a stale value corrupts routing / the body). _STRIP_REQUEST_HEADERS = frozenset( @@ -53,6 +67,7 @@ "content-length", "authorization", _PRINCIPAL_ID_HEADER, + _ON_BEHALF_OF_HEADER, } ) # We stream the response, so the upstream's framing headers no longer apply. @@ -73,8 +88,14 @@ def _upstream_base_url() -> str: ) -def build_app(*, base_url: str, principal: str) -> FastAPI: - """Build the forwarding FastAPI app for the given upstream and service principal.""" +def build_app(*, base_url: str, principal: str, on_behalf_of: str | None = None) -> FastAPI: + """Build the forwarding FastAPI app for the given upstream and service principal. + + When *on_behalf_of* is provided, every forwarded request also carries + ``X-NMP-Principal-On-Behalf-Of``, delegating to that principal so the + platform scopes access to what it can reach rather than the service + principal's full ServiceSystem reach. + """ 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) @@ -100,6 +121,8 @@ async def healthz() -> dict[str, str]: 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 + if on_behalf_of: + headers[_ON_BEHALF_OF_HEADER] = on_behalf_of 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) @@ -131,14 +154,22 @@ def run(parent_stop_signal: threading.Event | None = None) -> None: principal = os.environ.get(AUTH_PROXY_PRINCIPAL_ENVVAR) if not principal: raise RuntimeError(f"{AUTH_PROXY_PRINCIPAL_ENVVAR} is required for the auth-proxy sidecar") + on_behalf_of = os.environ.get(AUTH_PROXY_ON_BEHALF_OF_ENVVAR) or None 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) + app = build_app(base_url=base_url, principal=principal, on_behalf_of=on_behalf_of) 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) + logger.info( + "Starting auth-proxy sidecar on %s:%s -> %s (principal=service:%s, on_behalf_of=%s)", + host, + port, + base_url, + principal, + on_behalf_of or "", + ) if parent_stop_signal is None: server.run() return diff --git a/packages/nmp_common/tests/auth/test_workload_proxy.py b/packages/nmp_common/tests/auth/test_workload_proxy.py index 14546abc31..17660e3549 100644 --- a/packages/nmp_common/tests/auth/test_workload_proxy.py +++ b/packages/nmp_common/tests/auth/test_workload_proxy.py @@ -37,6 +37,72 @@ def test_forward_stamps_service_principal_and_preserves_path() -> None: assert "authorization" not in {k.lower() for k in sent.headers} +@respx.mock +def test_forward_stamps_on_behalf_of_when_configured() -> None: + upstream = "http://nemo-platform-api:8080" + route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={})) + app = build_app(base_url=upstream, principal="agents", on_behalf_of="user:alice") + client = TestClient(app) + + client.get("/apis/entities/v2/workspaces") + + sent = route.calls.last.request + # Service principal clears the route gate; on-behalf-of narrows access to the creator. + assert sent.headers["x-nmp-principal-id"] == "service:agents" + assert sent.headers["x-nmp-principal-on-behalf-of"] == "user:alice" + + +@respx.mock +def test_forward_omits_on_behalf_of_when_not_configured() -> None: + upstream = "http://nemo-platform-api:8080" + route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={})) + app = build_app(base_url=upstream, principal="agents") + client = TestClient(app) + + client.get("/apis/entities/v2/workspaces") + + sent = route.calls.last.request + assert "x-nmp-principal-on-behalf-of" not in {k.lower() for k in sent.headers} + + +@respx.mock +def test_forward_strips_inbound_on_behalf_of_to_prevent_spoofing() -> None: + upstream = "http://nemo-platform-api:8080" + route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={})) + # The delegated identity is baked in at deploy time; a co-located workload must + # not be able to override it (or inject one when none is configured) via headers. + app = build_app(base_url=upstream, principal="agents", on_behalf_of="user:alice") + client = TestClient(app) + + client.get( + "/apis/entities/v2/workspaces", + headers={ + "x-nmp-principal-id": "service:platform", + "x-nmp-principal-on-behalf-of": "user:attacker", + }, + ) + + sent = route.calls.last.request + assert sent.headers["x-nmp-principal-id"] == "service:agents" + assert sent.headers["x-nmp-principal-on-behalf-of"] == "user:alice" + + +@respx.mock +def test_forward_strips_inbound_on_behalf_of_when_none_configured() -> None: + upstream = "http://nemo-platform-api:8080" + route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={})) + app = build_app(base_url=upstream, principal="agents") + client = TestClient(app) + + client.get( + "/apis/entities/v2/workspaces", + headers={"x-nmp-principal-on-behalf-of": "user:attacker"}, + ) + + sent = route.calls.last.request + assert "x-nmp-principal-on-behalf-of" 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" diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py index 731450692f..654f9e6313 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py @@ -95,8 +95,16 @@ async def create_deployment( *, image: str | None = None, deployment_mode: DeploymentMode = "subprocess", + created_by: str | None = None, ) -> DeploymentInfo: - """Start the agent process; returns status="starting".""" + """Start the agent process; returns status="starting". + + ``created_by`` is the principal id that created the deployment. When + platform auth is enabled, container-mode backends delegate the deployed + agent's platform calls to this principal (on-behalf-of) so its access is + scoped to what the creator can reach rather than the agents service + principal's full reach. + """ ... @abstractmethod diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py index d3a4e122b0..c0079eaad6 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py @@ -188,6 +188,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None: port=port, image=dep.image or None, deployment_mode=dep.deployment_mode, + created_by=dep.created_by, ) except Exception as exc: logger.exception("Failed to start agent for deployment '%s'", dep.name) diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py index 28075662a7..f6a4a60ba4 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py @@ -284,6 +284,7 @@ def build_deployment_config( plugin_wheels_init_image: str | None = None, labels: dict[str, str] | None = None, auth_proxy_identity: str | None = None, + auth_proxy_on_behalf_of: str | None = None, ) -> DeploymentConfig: """Compile an agent into a long-running ``DeploymentConfig`` (Always). @@ -407,6 +408,7 @@ def build_deployment_config( "restart_policy": "Always", "auth_proxy_sidecar": auth_proxy_identity is not None, "auth_proxy_sidecar_identity": auth_proxy_identity, + "auth_proxy_sidecar_on_behalf_of": auth_proxy_on_behalf_of, } ) @@ -433,6 +435,7 @@ async def create_deployment( *, image: str | None = None, deployment_mode: DeploymentMode = "docker", + created_by: str | None = None, ) -> DeploymentInfo: """Create DeploymentConfig + Deployment entities for the agent container.""" del port # Host port is allocated by the deployments executor, not agents. @@ -472,10 +475,24 @@ async def create_deployment( # deployments plugin compiles the sidecar from the auth_proxy flags). The # agent targets the sidecar on localhost; the sidecar forwards to the # platform with a service-principal identity header. + # + # The sidecar also delegates to the deployment's creator via on-behalf-of + # (when known) so the running agent's platform access is scoped to what the + # creator can reach — the workspace(s) they have access to — rather than the + # agents service principal's full (ServiceSystem) reach. auth_proxy_identity: str | None = None + auth_proxy_on_behalf_of: str | None = None is_fabric = _is_fabric_agent_config(config) if platform_auth_enabled(): auth_proxy_identity = _AUTH_PROXY_IDENTITY + auth_proxy_on_behalf_of = created_by or None + if not auth_proxy_on_behalf_of: + logger.warning( + "Deployment %r has no creator principal; the agent will run as the " + "unscoped %s service principal without on-behalf-of delegation.", + name, + _AUTH_PROXY_IDENTITY, + ) rewrite_target = f"http://127.0.0.1:{auth_proxy_port()}" else: rewrite_target = gateway @@ -504,6 +521,7 @@ async def create_deployment( plugin_wheels_init_image=self._config.plugin_wheels_init_image, labels=deployment_labels, auth_proxy_identity=auth_proxy_identity, + auth_proxy_on_behalf_of=auth_proxy_on_behalf_of, ) await entities.create(deployment_config) try: diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py index f8a0b54270..62c88e74c5 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py @@ -219,9 +219,13 @@ async def create_deployment( *, image: str | None = None, deployment_mode: DeploymentMode = "subprocess", + created_by: str | None = None, ) -> DeploymentInfo: """Start a local deployment for NAT workflows or Platform-owned agent specs.""" - del image, deployment_mode + # created_by drives on-behalf-of delegation only for container modes (via + # the auth-proxy sidecar). Subprocess deployments run in-process on the + # platform host and do not use the sidecar, so it does not apply here. + del image, deployment_mode, created_by if config.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT: return await self._create_fabric_deployment(workspace, name, config, port) diff --git a/plugins/nemo-agents/tests/unit/test_runner_deployments.py b/plugins/nemo-agents/tests/unit/test_runner_deployments.py index 93470ba815..139473ed11 100644 --- a/plugins/nemo-agents/tests/unit/test_runner_deployments.py +++ b/plugins/nemo-agents/tests/unit/test_runner_deployments.py @@ -485,7 +485,12 @@ async def test_create_deployment_k8s_auth_on_requests_auth_proxy_sidecar() -> No patch("nemo_agents_plugin.runner.deployments_backend.auth_proxy_port", return_value=8090), ): info = await backend.create_deployment( - workspace="default", name="hello-dep", config=config, port=0, deployment_mode="k8s" + workspace="default", + name="hello-dep", + config=config, + port=0, + deployment_mode="k8s", + created_by="user:alice", ) assert info.status == "starting" created_config = entities.create.await_args_list[0].args[0] @@ -494,6 +499,9 @@ async def test_create_deployment_k8s_auth_on_requests_auth_proxy_sidecar() -> No # agents layer does not build the container itself. assert created_config.auth_proxy_sidecar is True assert created_config.auth_proxy_sidecar_identity == "agents" + # The creator principal is delegated via on-behalf-of so the running agent's + # platform access is scoped to what the creator can reach. + assert created_config.auth_proxy_sidecar_on_behalf_of == "user:alice" assert [c.name for c in created_config.containers] == ["agent"] assert [c.name for c in created_config.init_containers] == [] @@ -504,6 +512,32 @@ async def test_create_deployment_k8s_auth_on_requests_auth_proxy_sidecar() -> No ) +@pytest.mark.asyncio +async def test_create_deployment_k8s_auth_on_without_creator_omits_on_behalf_of() -> None: + # When auth is on but the deployment has no known creator, the sidecar still + # stamps the service principal but cannot delegate — access is unscoped. + backend = _backend( + default_image="nmp-api:latest", + default_executor="k8s", + k8s_internal_base_url="http://nmp-api:8080", + ) + entities = AsyncMock() + backend._entities = entities + with ( + patch("nemo_agents_plugin.runner.deployments_backend.get_base_url", return_value="http://localhost:8080"), + patch("nemo_agents_plugin.runner.deployments_backend.platform_auth_enabled", return_value=True), + patch("nemo_agents_plugin.runner.deployments_backend.auth_proxy_port", return_value=8090), + ): + info = await backend.create_deployment( + workspace="default", name="hello-dep", config={}, port=0, deployment_mode="k8s" + ) + assert info.status == "starting" + created_config = entities.create.await_args_list[0].args[0] + assert created_config.auth_proxy_sidecar is True + assert created_config.auth_proxy_sidecar_identity == "agents" + assert created_config.auth_proxy_sidecar_on_behalf_of is None + + @pytest.mark.asyncio async def test_create_deployment_k8s_auth_off_no_sidecar() -> None: backend = _backend( diff --git a/plugins/nemo-deployments/openapi/openapi.yaml b/plugins/nemo-deployments/openapi/openapi.yaml index 7947d04cec..d568c0d74e 100644 --- a/plugins/nemo-deployments/openapi/openapi.yaml +++ b/plugins/nemo-deployments/openapi/openapi.yaml @@ -928,6 +928,15 @@ components: into ''X-NMP-Principal-Id: service:''). Required when auth_proxy_sidecar is True.' type: string + authProxySidecarOnBehalfOf: + title: Authproxysidecaronbehalfof + description: Optional principal id the auth-proxy sidecar delegates to via + 'X-NMP-Principal-On-Behalf-Of'. When set, the service principal acts on + behalf of this identity so the platform scopes the workload's access to + what that principal can reach (e.g. the deployment's creator) rather than + the service principal's full reach. Only meaningful when auth_proxy_sidecar + is True. + type: string id: type: string title: Id diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/auth_proxy.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/auth_proxy.py index b1027b98c1..fd7f37f8c6 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/auth_proxy.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/auth_proxy.py @@ -35,6 +35,7 @@ AUTH_PROXY_CONTAINER_NAME = "auth-proxy" _NATIVE_SIDECAR_RESTART_POLICY: RestartPolicy = "Always" _AUTH_PROXY_PRINCIPAL_ENVVAR = "NMP_AUTH_PROXY_PRINCIPAL" +_AUTH_PROXY_ON_BEHALF_OF_ENVVAR = "NMP_AUTH_PROXY_ON_BEHALF_OF" _AUTH_PROXY_HOST_ENVVAR = "NMP_AUTH_PROXY_HOST" _AUTH_PROXY_PORT_ENVVAR = "NMP_AUTH_PROXY_PORT" @@ -84,19 +85,24 @@ def build_auth_proxy_container(config: DeploymentConfig, *, docker: bool = False deployments_config = get_nemo_config(DeploymentsConfig) # Guaranteed present: DeploymentConfig validates identity when the sidecar is enabled. identity = config.auth_proxy_sidecar_identity + on_behalf_of = config.auth_proxy_sidecar_on_behalf_of port = deployments_config.auth_proxy_port image = deployments_config.auth_proxy_image or get_qualified_image(deployments_config.auth_proxy_image_name) + env = [ + EnvVar(name="NMP_BASE_URL", value=_upstream_base_url(docker=docker)), + EnvVar(name=_AUTH_PROXY_PRINCIPAL_ENVVAR, value=identity), + EnvVar(name=_AUTH_PROXY_HOST_ENVVAR, value="127.0.0.1"), + EnvVar(name=_AUTH_PROXY_PORT_ENVVAR, value=str(port)), + ] + if on_behalf_of: + env.append(EnvVar(name=_AUTH_PROXY_ON_BEHALF_OF_ENVVAR, value=on_behalf_of)) + return Container( name=AUTH_PROXY_CONTAINER_NAME, image=image, command=["nemo", "services", "run", "--sidecars", "auth-proxy"], - env=[ - EnvVar(name="NMP_BASE_URL", value=_upstream_base_url(docker=docker)), - EnvVar(name=_AUTH_PROXY_PRINCIPAL_ENVVAR, value=identity), - EnvVar(name=_AUTH_PROXY_HOST_ENVVAR, value="127.0.0.1"), - EnvVar(name=_AUTH_PROXY_PORT_ENVVAR, value=str(port)), - ], + env=env, ).model_copy( update={ "restart_policy": _NATIVE_SIDECAR_RESTART_POLICY, diff --git a/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py index 5d94482d0a..1fea3fd744 100644 --- a/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py +++ b/plugins/nemo-deployments/src/nemo_deployments_plugin/entities.py @@ -292,6 +292,17 @@ class DeploymentConfig(NemoEntity, entity_type=ENTITY_TYPE_DEPLOYMENT_CONFIG): "'X-NMP-Principal-Id: service:'). Required when auth_proxy_sidecar is True." ), ) + auth_proxy_sidecar_on_behalf_of: str | None = Field( + default=None, + alias="authProxySidecarOnBehalfOf", + description=( + "Optional principal id the auth-proxy sidecar delegates to via " + "'X-NMP-Principal-On-Behalf-Of'. When set, the service principal acts on behalf of this " + "identity so the platform scopes the workload's access to what that principal can reach " + "(e.g. the deployment's creator) rather than the service principal's full reach. " + "Only meaningful when auth_proxy_sidecar is True." + ), + ) model_config = {"populate_by_name": True} diff --git a/plugins/nemo-deployments/tests/unit/test_auth_proxy.py b/plugins/nemo-deployments/tests/unit/test_auth_proxy.py index 07f1bf2510..b39deb1162 100644 --- a/plugins/nemo-deployments/tests/unit/test_auth_proxy.py +++ b/plugins/nemo-deployments/tests/unit/test_auth_proxy.py @@ -53,6 +53,37 @@ def test_builds_sidecar_when_requested_and_auth_on() -> None: assert "127.0.0.1" in " ".join(container.readiness_probe.exec_action.command) +def test_sidecar_stamps_on_behalf_of_when_set() -> None: + with ( + patch(f"{_MOD}.platform_auth_enabled", return_value=True), + patch(f"{_MOD}.get_qualified_image", return_value="my-registry/nmp-api:local"), + patch(f"{_MOD}._upstream_base_url", return_value="http://nemo-platform-api:8080"), + ): + container = build_auth_proxy_container( + _config( + auth_proxy_sidecar=True, + auth_proxy_sidecar_identity="agents", + auth_proxy_sidecar_on_behalf_of="user:alice", + ) + ) + assert container is not None + env = {e.name: e.value for e in container.env} + assert env["NMP_AUTH_PROXY_PRINCIPAL"] == "agents" + assert env["NMP_AUTH_PROXY_ON_BEHALF_OF"] == "user:alice" + + +def test_sidecar_omits_on_behalf_of_when_unset() -> None: + with ( + patch(f"{_MOD}.platform_auth_enabled", return_value=True), + patch(f"{_MOD}.get_qualified_image", return_value="my-registry/nmp-api:local"), + patch(f"{_MOD}._upstream_base_url", return_value="http://nemo-platform-api:8080"), + ): + container = build_auth_proxy_container(_config(auth_proxy_sidecar=True, auth_proxy_sidecar_identity="agents")) + assert container is not None + env = {e.name: e.value for e in container.env} + assert "NMP_AUTH_PROXY_ON_BEHALF_OF" not in env + + def test_sidecar_without_identity_is_rejected() -> None: # No default identity: a config requesting the sidecar without an identity # is invalid and fails validation (surfaced as a 4xx at the create endpoint). diff --git a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/authz.py b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/authz.py new file mode 100644 index 0000000000..1c88c83daa --- /dev/null +++ b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/authz.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Request-time authorization for the inference proxy path. + +The shared ``AuthMiddleware`` route gate authorizes every request against the +PDP, but for a **service principal** the PDP takes the ServiceSystem bypass +(wildcard permission) and does not narrow on the ``on-behalf-of`` identity. That +is correct for genuine internal callers that act only as themselves, but a +service principal that delegates (e.g. an agent deployment acting as its creator +via ``X-NMP-Principal-On-Behalf-Of``) must not inherit that platform-wide reach: +its access should be scoped to what the delegated user can reach. + +The proxy handlers themselves do no per-caller access control (they resolve +models/providers from in-memory caches), so this module adds the missing check: +when the caller is a *delegated* service principal, verify the on-behalf-of user +holds the endpoint's required permission in the target workspace. Non-delegated +callers are untouched — plain users are already gated by the route gate, and a +non-delegated service principal keeps the existing bypass. +""" + +from __future__ import annotations + +import logging + +from fastapi import HTTPException, status +from nmp.common.auth.client import AuthClient +from nmp.common.auth.dependencies import auth_client_context + +logger = logging.getLogger(__name__) + +# Permission gating the inference proxy endpoints in a workspace. These mirror the +# central endpoint definitions in the auth service's static-authz.yaml for the +# inference-gateway ``.../{openai,model,provider}/...`` proxy routes. +OPENAI_EXEC_PERMISSION = "inference.gateway.openai.exec" +MODEL_EXEC_PERMISSION = "inference.gateway.model.exec" +PROVIDER_EXEC_PERMISSION = "inference.gateway.provider.exec" +# The provider readiness probe is gated by the provider read permission, not exec. +PROVIDER_READ_PERMISSION = "inference.providers.read" + + +async def enforce_delegated_workspace_access(workspace: str, permission: str) -> None: + """Scope a delegated service-principal request to the on-behalf-of user. + + No-op unless the current principal is a *delegated* service principal + (privileged id ``service:*`` with ``on_behalf_of`` set). In that case the + request is allowed only if the on-behalf-of user holds *permission* in + *workspace*; otherwise a 403 is raised. + + This is deliberately narrow: it never *grants* access the route gate denied, + it only *removes* the service-principal bypass for delegated calls so a + deployed workload cannot reach workspaces its creator cannot. + + Args: + workspace: Target workspace from the request path. + permission: Required permission (one of the ``inference.gateway.*.exec`` + constants in this module). + + Raises: + HTTPException: 403 when the on-behalf-of user lacks *permission* in + *workspace*. + """ + auth_client = auth_client_context.get() + # No auth context (auth disabled / not configured) or auth disabled: nothing + # to scope — the route gate already made the allow/deny decision. + if auth_client is None or not auth_client.auth_enabled: + return + + principal = auth_client.principal + # Only delegated service principals need narrowing. A plain user was already + # gated by the route gate as themselves; a non-delegated service principal + # keeps its existing (intended) internal bypass. + if not principal.is_privileged or not principal.is_delegated: + return + + allowed = await _on_behalf_of_has_permission(auth_client, workspace, permission) + if not allowed: + logger.info( + "Denying delegated inference request: on-behalf-of=%s lacks %s in workspace=%s (service=%s)", + principal.on_behalf_of, + permission, + workspace, + principal.id, + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + f"On-behalf-of principal '{principal.on_behalf_of}' is not authorized " + f"for inference in workspace '{workspace}'." + ), + ) + + +async def _on_behalf_of_has_permission(auth_client: AuthClient, workspace: str, permission: str) -> bool: + """Return whether the on-behalf-of user holds *permission* in *workspace*. + + Uses :meth:`AuthClient.on_behalf_of_has_permissions`, which evaluates the PDP + as the delegated user (not the service principal), so the ServiceSystem + wildcard does not apply. + """ + return await auth_client.on_behalf_of_has_permissions(workspace, [permission]) diff --git a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/models.py b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/models.py index 59906f5065..61c07faa84 100644 --- a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/models.py +++ b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/models.py @@ -6,6 +6,10 @@ from aiohttp import ClientSession from fastapi import APIRouter, Depends, Request, Response, status +from nmp.core.inference_gateway.api.authz import ( + MODEL_EXEC_PERMISSION, + enforce_delegated_workspace_access, +) from nmp.core.inference_gateway.api.dependencies import ( global_http_client, global_middleware_registry, @@ -91,6 +95,12 @@ async def model_entity_proxy( LoRA escape-hatches, etc. Requests for which no VirtualModel can be found return `404`. """ + # Scope delegated (on-behalf-of) service-principal calls to the target + # workspace before any routing — including the mock short-circuit — so a + # delegated workload cannot reach a workspace its creator cannot. + validate_entity_name(workspace, field_name="workspace") + await enforce_delegated_workspace_access(workspace, MODEL_EXEC_PERMISSION) + # If mock mode enabled and request has explicit mock response, skip model lookup if is_mock_request(request): return await handle_mock_request(request=request, trailing_uri=trailing_uri) @@ -99,7 +109,6 @@ async def model_entity_proxy( # ``base&adapters/{adapter_ws}/{adapter_name}``; ``validate_model_entity_name`` # accepts that shape (per-segment NAME_PATTERN) while still rejecting bare # invalid names. - validate_entity_name(workspace, field_name="workspace") validate_model_entity_name(name, field_name="name") logger.info(f"Model entity proxy request: {workspace}/{name}/-/{trailing_uri}") diff --git a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/openai.py b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/openai.py index 2aa4f70b58..82895e1eb2 100644 --- a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/openai.py +++ b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/openai.py @@ -7,6 +7,10 @@ from aiohttp import ClientSession from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from nmp.core.inference_gateway.api.authz import ( + OPENAI_EXEC_PERMISSION, + enforce_delegated_workspace_access, +) from nmp.core.inference_gateway.api.dependencies import ( global_http_client, global_middleware_registry, @@ -114,6 +118,7 @@ async def openai_get_models( VirtualModels scoped to the request workspace. """ validate_entity_name(workspace, field_name="workspace") + await enforce_delegated_workspace_access(workspace, OPENAI_EXEC_PERMISSION) all_oai_models: list[OpenAIModelResp] = [] @@ -148,6 +153,7 @@ async def openai_get_model( validate_entity_name(workspace, field_name="workspace") validate_model_entity_name(model_name, field_name="model") + await enforce_delegated_workspace_access(workspace, OPENAI_EXEC_PERMISSION) if virtual_model_cache.get(workspace, model_name) is None: raise_virtual_model_not_found(workspace, model_name) @@ -216,6 +222,12 @@ async def openai_proxy( LoRA escape-hatches, etc. Requests for which no VirtualModel can be found return `404`. """ + # Scope delegated (on-behalf-of) service-principal calls to the target + # workspace before any routing — including the mock short-circuit — so a + # delegated workload cannot reach a workspace its creator cannot. + validate_entity_name(workspace, field_name="workspace") + await enforce_delegated_workspace_access(workspace, OPENAI_EXEC_PERMISSION) + # If mock mode enabled and request has explicit mock response, skip model lookup if is_mock_request(request): return await handle_mock_request(request=request, trailing_uri=trailing_uri) @@ -235,7 +247,6 @@ async def openai_proxy( # If body contains "workspace/model", use only the model name part. model_name = body_model.removeprefix(f"{workspace}/") - validate_entity_name(workspace, field_name="workspace") validate_model_entity_name(model_name, field_name="model") virtual_model = virtual_model_cache.get(workspace, model_name) diff --git a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/providers.py b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/providers.py index b4b61c4abc..7afa5bcc9a 100644 --- a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/providers.py +++ b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/v2/providers.py @@ -6,6 +6,11 @@ from aiohttp import ClientSession from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from nmp.core.inference_gateway.api.authz import ( + PROVIDER_EXEC_PERMISSION, + PROVIDER_READ_PERMISSION, + enforce_delegated_workspace_access, +) from nmp.core.inference_gateway.api.dependencies import global_http_client, global_model_cache from nmp.core.inference_gateway.api.errors import raise_unresolved_provider_secret from nmp.core.inference_gateway.api.mock_provider import ( @@ -46,6 +51,7 @@ async def provider_ready( 404 Not Found if the provider is not yet in the gateway's cache """ validate_workspace_and_name(workspace, name) + await enforce_delegated_workspace_access(workspace, PROVIDER_READ_PERMISSION) logger.info(f"Provider ready check: {workspace}/{name}") model_info = model_cache.get_from_provider(workspace, name) @@ -114,11 +120,16 @@ async def provider_proxy( """ Proxy requests to provider inference endpoints. """ + # Scope delegated (on-behalf-of) service-principal calls to the target + # workspace before any routing — including the mock short-circuit — so a + # delegated workload cannot reach a workspace its creator cannot. + validate_workspace_and_name(workspace, name) + await enforce_delegated_workspace_access(workspace, PROVIDER_EXEC_PERMISSION) + # If mock mode enabled and request has explicit mock response, skip provider lookup if is_mock_request(request): return await handle_mock_request(request=request, trailing_uri=trailing_uri) - validate_workspace_and_name(workspace, name) model_info = model_cache.get_from_provider(workspace, name) if model_info is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"Model provider not found for {workspace}/{name}") diff --git a/services/core/inference-gateway/tests/integration/test_igw_with_auth.py b/services/core/inference-gateway/tests/integration/test_igw_with_auth.py index 56b4d8f7d9..1b8a03d5a5 100644 --- a/services/core/inference-gateway/tests/integration/test_igw_with_auth.py +++ b/services/core/inference-gateway/tests/integration/test_igw_with_auth.py @@ -770,3 +770,102 @@ def test_regular_user_without_role_is_still_denied(self, sdk: NeMoPlatform): }, ) assert response.status_code == 403 + + +@pytest.mark.integration +class TestIGWDelegatedServicePrincipalAccess: + """A *delegated* service principal (service:* + on-behalf-of) is scoped to the OBO user. + + Unlike a bare service principal (which takes the ServiceSystem bypass), a service + principal that carries X-NMP-Principal-On-Behalf-Of must only reach workspaces the + delegated user can reach. This is the agent-deployment case: the deployed agent's + auth-proxy sidecar stamps service:agents on-behalf-of the deployment's creator. + """ + + SERVICE_PRINCIPAL_AGENTS = "service:agents" + + @staticmethod + def _delegated_headers(on_behalf_of: str) -> dict[str, str]: + return { + "X-NMP-Principal-Id": TestIGWDelegatedServicePrincipalAccess.SERVICE_PRINCIPAL_AGENTS, + "X-NMP-Principal-On-Behalf-Of": on_behalf_of, + "X-NMP-Principal-On-Behalf-Of-Email": on_behalf_of, + } + + def test_delegated_denied_when_obo_user_lacks_role(self, sdk: NeMoPlatform): + # The OBO user has NO role in the workspace: the service bypass must not apply. + workspace = short_unique_name("igw-obo-d") + obo_email = unique_email("obo-norole") + + admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_sdk.workspaces.create(name=workspace) + add_mock_provider( + admin_sdk, + workspace=workspace, + name=short_unique_name("mdl"), + mock_response_body=MOCK_CHAT_RESPONSE, + ) + + response = sdk._client.get( + f"/apis/inference-gateway/v2/workspaces/{workspace}/openai/-/v1/models", + headers=self._delegated_headers(obo_email), + ) + assert response.status_code == 403 + + def test_delegated_allowed_when_obo_user_has_role(self, sdk: NeMoPlatform): + # The OBO user is granted a role in the workspace: the delegated call is allowed. + workspace = short_unique_name("igw-obo-a") + obo_email = unique_email("obo-viewer") + + admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_sdk.workspaces.create(name=workspace) + grant_workspace_role(admin_sdk, workspace=workspace, principal=obo_email, roles=["Viewer"]) + + response = sdk._client.get( + f"/apis/inference-gateway/v2/workspaces/{workspace}/openai/-/v1/models", + headers=self._delegated_headers(obo_email), + ) + assert response.status_code == 200 + + def test_delegated_openai_proxy_denied_when_obo_user_lacks_role(self, sdk: NeMoPlatform): + workspace = short_unique_name("igw-obo-po") + model_name = short_unique_name("mdl") + obo_email = unique_email("obo-norole") + + admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_sdk.workspaces.create(name=workspace) + add_mock_provider( + admin_sdk, + workspace=workspace, + name=model_name, + mock_response_body=MOCK_CHAT_RESPONSE, + ) + + response = sdk._client.post( + f"/apis/inference-gateway/v2/workspaces/{workspace}/openai/-/v1/chat/completions", + json={"model": f"{workspace}/{model_name}", "messages": [{"role": "user", "content": "hi"}]}, + headers=self._delegated_headers(obo_email), + ) + assert response.status_code == 403 + + def test_delegated_openai_proxy_allowed_when_obo_user_has_role(self, sdk: NeMoPlatform): + workspace = short_unique_name("igw-obo-pa") + model_name = short_unique_name("mdl") + obo_email = unique_email("obo-editor") + + admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_sdk.workspaces.create(name=workspace) + add_mock_provider( + admin_sdk, + workspace=workspace, + name=model_name, + mock_response_body=MOCK_CHAT_RESPONSE, + ) + grant_workspace_role(admin_sdk, workspace=workspace, principal=obo_email, roles=["Editor"]) + + response = sdk._client.post( + f"/apis/inference-gateway/v2/workspaces/{workspace}/openai/-/v1/chat/completions", + json={"model": f"{workspace}/{model_name}", "messages": [{"role": "user", "content": "hi"}]}, + headers=self._delegated_headers(obo_email), + ) + assert response.status_code == 200 diff --git a/services/core/inference-gateway/tests/unit/test_authz.py b/services/core/inference-gateway/tests/unit/test_authz.py new file mode 100644 index 0000000000..9be3cac1b5 --- /dev/null +++ b/services/core/inference-gateway/tests/unit/test_authz.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for delegated (on-behalf-of) workspace access enforcement on the proxy path.""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException +from nmp.common.auth.dependencies import auth_client_context +from nmp.common.auth.models import Principal +from nmp.core.inference_gateway.api.authz import ( + OPENAI_EXEC_PERMISSION, + enforce_delegated_workspace_access, +) + + +def _auth_client(principal: Principal, *, enabled: bool = True, allowed: bool = True) -> MagicMock: + client = MagicMock() + client.auth_enabled = enabled + client.principal = principal + client.on_behalf_of_has_permissions = AsyncMock(return_value=allowed) + return client + + +@pytest.fixture(autouse=True) +def _clear_auth_context() -> Iterator[None]: + token = auth_client_context.set(None) + try: + yield + finally: + auth_client_context.reset(token) + + +@pytest.mark.asyncio +async def test_no_auth_context_is_noop() -> None: + # No middleware / no context: the route gate already decided; do not raise. + await enforce_delegated_workspace_access("ws", OPENAI_EXEC_PERMISSION) + + +@pytest.mark.asyncio +async def test_auth_disabled_is_noop() -> None: + client = _auth_client(Principal(id="service:agents", on_behalf_of="user:alice"), enabled=False, allowed=False) + auth_client_context.set(client) + # Auth disabled short-circuits before any PDP call. + await enforce_delegated_workspace_access("ws", OPENAI_EXEC_PERMISSION) + client.on_behalf_of_has_permissions.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_plain_user_is_noop() -> None: + # A non-service principal was already gated by the route gate as itself. + client = _auth_client(Principal(id="user:alice", email="alice@example.com"), allowed=False) + auth_client_context.set(client) + await enforce_delegated_workspace_access("ws", OPENAI_EXEC_PERMISSION) + client.on_behalf_of_has_permissions.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_non_delegated_service_principal_is_noop() -> None: + # A service principal that is NOT delegating keeps its internal bypass. + client = _auth_client(Principal(id="service:agents"), allowed=False) + auth_client_context.set(client) + await enforce_delegated_workspace_access("ws", OPENAI_EXEC_PERMISSION) + client.on_behalf_of_has_permissions.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delegated_service_principal_allowed_when_obo_user_has_permission() -> None: + client = _auth_client(Principal(id="service:agents", on_behalf_of="user:alice"), allowed=True) + auth_client_context.set(client) + await enforce_delegated_workspace_access("ws", OPENAI_EXEC_PERMISSION) + client.on_behalf_of_has_permissions.assert_awaited_once_with("ws", [OPENAI_EXEC_PERMISSION]) + + +@pytest.mark.asyncio +async def test_delegated_service_principal_denied_when_obo_user_lacks_permission() -> None: + client = _auth_client(Principal(id="service:agents", on_behalf_of="user:mallory"), allowed=False) + auth_client_context.set(client) + with pytest.raises(HTTPException) as exc: + await enforce_delegated_workspace_access("secret-ws", OPENAI_EXEC_PERMISSION) + assert exc.value.status_code == 403 + assert "secret-ws" in exc.value.detail + client.on_behalf_of_has_permissions.assert_awaited_once_with("secret-ws", [OPENAI_EXEC_PERMISSION]) From 271b7df8d0085cab84e87d40624ac1cd22e44e2b Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Thu, 30 Jul 2026 15:43:47 -0600 Subject: [PATCH 2/2] fix(auth): harden auth-proxy OBO header handling Address PR review feedback on the auth-proxy sidecar: - Strip inbound X-NMP-Principal-On-Behalf-Of-Email and -Groups companion headers, not just the OBO id. The platform derives the delegated user's effective groups/email from these headers and feeds them to the PDP, so a co-located workload could otherwise pair our stamped OBO id with attacker-chosen groups/email and be authorized as those, defeating the scoping. Extend the spoof-strip tests to cover both companion headers in the configured and unconfigured paths. - Do not log the delegated principal id in the sidecar startup line; log a boolean (delegated=) instead to keep creator identifiers out of logs. Signed-off-by: Ben McCown --- .../nmp/common/auth/workload_proxy/main.py | 14 +++++++++++-- .../tests/auth/test_workload_proxy.py | 20 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py b/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py index 174579d5fb..018517b000 100644 --- a/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py +++ b/packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py @@ -55,6 +55,14 @@ _READ_TIMEOUT_ENVVAR = "NMP_AUTH_PROXY_READ_TIMEOUT" _PRINCIPAL_ID_HEADER = "x-nmp-principal-id" _ON_BEHALF_OF_HEADER = "x-nmp-principal-on-behalf-of" +# Companion metadata for the on-behalf-of principal. The platform derives the +# delegated user's groups/email from these (Principal.from_headers -> effective_*), +# and effective_groups/effective_email feed the PDP authorization input. We stamp +# only the OBO id here, so any inbound companion headers are untrusted and must be +# dropped — otherwise a colocated workload could pair our stamped OBO id with +# attacker-chosen groups/email and be evaluated with those, defeating the scoping. +_ON_BEHALF_OF_EMAIL_HEADER = "x-nmp-principal-on-behalf-of-email" +_ON_BEHALF_OF_GROUPS_HEADER = "x-nmp-principal-on-behalf-of-groups" # Minimal request-header sanitization. We only drop what would be actively wrong: # - the workload's own credential / principal / on-behalf-of headers (we set the @@ -68,6 +76,8 @@ "authorization", _PRINCIPAL_ID_HEADER, _ON_BEHALF_OF_HEADER, + _ON_BEHALF_OF_EMAIL_HEADER, + _ON_BEHALF_OF_GROUPS_HEADER, } ) # We stream the response, so the upstream's framing headers no longer apply. @@ -163,12 +173,12 @@ def run(parent_stop_signal: threading.Event | None = None) -> None: server = uvicorn.Server(config) logger.info( - "Starting auth-proxy sidecar on %s:%s -> %s (principal=service:%s, on_behalf_of=%s)", + "Starting auth-proxy sidecar on %s:%s -> %s (principal=service:%s, delegated=%s)", host, port, base_url, principal, - on_behalf_of or "", + on_behalf_of is not None, ) if parent_stop_signal is None: server.run() diff --git a/packages/nmp_common/tests/auth/test_workload_proxy.py b/packages/nmp_common/tests/auth/test_workload_proxy.py index 17660e3549..62c3b9f353 100644 --- a/packages/nmp_common/tests/auth/test_workload_proxy.py +++ b/packages/nmp_common/tests/auth/test_workload_proxy.py @@ -79,12 +79,21 @@ def test_forward_strips_inbound_on_behalf_of_to_prevent_spoofing() -> None: headers={ "x-nmp-principal-id": "service:platform", "x-nmp-principal-on-behalf-of": "user:attacker", + # Companion metadata must not be smuggled onto our stamped OBO id: + # the platform derives effective groups/email from these and feeds + # them to the PDP, so attacker-chosen values would escalate. + "x-nmp-principal-on-behalf-of-email": "attacker@evil.test", + "x-nmp-principal-on-behalf-of-groups": "platform-admins", }, ) sent = route.calls.last.request + sent_keys = {k.lower() for k in sent.headers} assert sent.headers["x-nmp-principal-id"] == "service:agents" assert sent.headers["x-nmp-principal-on-behalf-of"] == "user:alice" + # The inbound companion headers are dropped (we stamp only the OBO id). + assert "x-nmp-principal-on-behalf-of-email" not in sent_keys + assert "x-nmp-principal-on-behalf-of-groups" not in sent_keys @respx.mock @@ -96,11 +105,18 @@ def test_forward_strips_inbound_on_behalf_of_when_none_configured() -> None: client.get( "/apis/entities/v2/workspaces", - headers={"x-nmp-principal-on-behalf-of": "user:attacker"}, + headers={ + "x-nmp-principal-on-behalf-of": "user:attacker", + "x-nmp-principal-on-behalf-of-email": "attacker@evil.test", + "x-nmp-principal-on-behalf-of-groups": "platform-admins", + }, ) sent = route.calls.last.request - assert "x-nmp-principal-on-behalf-of" not in {k.lower() for k in sent.headers} + sent_keys = {k.lower() for k in sent.headers} + assert "x-nmp-principal-on-behalf-of" not in sent_keys + assert "x-nmp-principal-on-behalf-of-email" not in sent_keys + assert "x-nmp-principal-on-behalf-of-groups" not in sent_keys @respx.mock