Skip to content

Commit 3ab873b

Browse files
authored
fix(agents): scope agent deployments to creator via on-behalf-of (#1000)
* 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 <bmccown@nvidia.com> * 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=<bool>) instead to keep creator identifiers out of logs. Signed-off-by: Ben McCown <bmccown@nvidia.com> --------- Signed-off-by: Ben McCown <bmccown@nvidia.com>
1 parent fef4a5d commit 3ab873b

17 files changed

Lines changed: 581 additions & 18 deletions

File tree

packages/nmp_common/src/nmp/common/auth/workload_proxy/main.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
use (``get_platform_sdk(as_service=...)``); the proxy exists only for workloads
1414
that cannot set the header themselves.
1515
16+
When ``NMP_AUTH_PROXY_ON_BEHALF_OF`` is set, the proxy additionally stamps
17+
``X-NMP-Principal-On-Behalf-Of`` so the platform authorizes the request as that
18+
delegated principal rather than granting the service principal's full
19+
(ServiceSystem) reach. This scopes a deployed workload's platform access to the
20+
identity that created it (e.g. an agent deployment acting as its creator). The
21+
delegated identity is baked in at deploy time and is *not* taken from the
22+
incoming request — the inbound principal/OBO headers are stripped so a co-located
23+
workload cannot spoof a different identity.
24+
1625
Started via ``nemo services run --sidecars auth-proxy``.
1726
"""
1827

@@ -36,15 +45,28 @@
3645
AUTH_PROXY_PORT_ENVVAR = "NMP_AUTH_PROXY_PORT"
3746
# Service-principal name stamped on forwarded requests (e.g. "agents").
3847
AUTH_PROXY_PRINCIPAL_ENVVAR = "NMP_AUTH_PROXY_PRINCIPAL"
48+
# Optional principal id to delegate to via on-behalf-of (e.g. the workload's
49+
# creator). When set, the service principal acts on behalf of this identity so
50+
# the platform scopes access to what that principal can reach.
51+
AUTH_PROXY_ON_BEHALF_OF_ENVVAR = "NMP_AUTH_PROXY_ON_BEHALF_OF"
3952
DEFAULT_AUTH_PROXY_HOST = "127.0.0.1"
4053
DEFAULT_AUTH_PROXY_PORT = 8090
4154

4255
_READ_TIMEOUT_ENVVAR = "NMP_AUTH_PROXY_READ_TIMEOUT"
4356
_PRINCIPAL_ID_HEADER = "x-nmp-principal-id"
57+
_ON_BEHALF_OF_HEADER = "x-nmp-principal-on-behalf-of"
58+
# Companion metadata for the on-behalf-of principal. The platform derives the
59+
# delegated user's groups/email from these (Principal.from_headers -> effective_*),
60+
# and effective_groups/effective_email feed the PDP authorization input. We stamp
61+
# only the OBO id here, so any inbound companion headers are untrusted and must be
62+
# dropped — otherwise a colocated workload could pair our stamped OBO id with
63+
# attacker-chosen groups/email and be evaluated with those, defeating the scoping.
64+
_ON_BEHALF_OF_EMAIL_HEADER = "x-nmp-principal-on-behalf-of-email"
65+
_ON_BEHALF_OF_GROUPS_HEADER = "x-nmp-principal-on-behalf-of-groups"
4466

4567
# Minimal request-header sanitization. We only drop what would be actively wrong:
46-
# - the workload's own credential / principal header (we set the identity), so it
47-
# can't be spoofed or conflict with what we stamp;
68+
# - the workload's own credential / principal / on-behalf-of headers (we set the
69+
# identity), so they can't be spoofed or conflict with what we stamp;
4870
# - host and content-length, which httpx recomputes for the upstream request
4971
# (a stale value corrupts routing / the body).
5072
_STRIP_REQUEST_HEADERS = frozenset(
@@ -53,6 +75,9 @@
5375
"content-length",
5476
"authorization",
5577
_PRINCIPAL_ID_HEADER,
78+
_ON_BEHALF_OF_HEADER,
79+
_ON_BEHALF_OF_EMAIL_HEADER,
80+
_ON_BEHALF_OF_GROUPS_HEADER,
5681
}
5782
)
5883
# We stream the response, so the upstream's framing headers no longer apply.
@@ -73,8 +98,14 @@ def _upstream_base_url() -> str:
7398
)
7499

75100

76-
def build_app(*, base_url: str, principal: str) -> FastAPI:
77-
"""Build the forwarding FastAPI app for the given upstream and service principal."""
101+
def build_app(*, base_url: str, principal: str, on_behalf_of: str | None = None) -> FastAPI:
102+
"""Build the forwarding FastAPI app for the given upstream and service principal.
103+
104+
When *on_behalf_of* is provided, every forwarded request also carries
105+
``X-NMP-Principal-On-Behalf-Of``, delegating to that principal so the
106+
platform scopes access to what it can reach rather than the service
107+
principal's full ServiceSystem reach.
108+
"""
78109
principal_id = principal if principal.startswith("service:") else f"service:{principal}"
79110
read_timeout = float(os.environ.get(_READ_TIMEOUT_ENVVAR, "300"))
80111
timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=60.0, pool=10.0)
@@ -100,6 +131,8 @@ async def healthz() -> dict[str, str]:
100131
async def forward(request: Request, path: str) -> StreamingResponse:
101132
headers = {k: v for k, v in request.headers.items() if k.lower() not in _STRIP_REQUEST_HEADERS}
102133
headers[_PRINCIPAL_ID_HEADER] = principal_id
134+
if on_behalf_of:
135+
headers[_ON_BEHALF_OF_HEADER] = on_behalf_of
103136
url = httpx.URL(path="/" + path, query=request.url.query.encode("utf-8"))
104137
body = await request.body()
105138
upstream = client.build_request(request.method, url, headers=headers, content=body)
@@ -131,14 +164,22 @@ def run(parent_stop_signal: threading.Event | None = None) -> None:
131164
principal = os.environ.get(AUTH_PROXY_PRINCIPAL_ENVVAR)
132165
if not principal:
133166
raise RuntimeError(f"{AUTH_PROXY_PRINCIPAL_ENVVAR} is required for the auth-proxy sidecar")
167+
on_behalf_of = os.environ.get(AUTH_PROXY_ON_BEHALF_OF_ENVVAR) or None
134168
host = os.environ.get(AUTH_PROXY_HOST_ENVVAR, DEFAULT_AUTH_PROXY_HOST)
135169
port = int(os.environ.get(AUTH_PROXY_PORT_ENVVAR, str(DEFAULT_AUTH_PROXY_PORT)))
136-
app = build_app(base_url=base_url, principal=principal)
170+
app = build_app(base_url=base_url, principal=principal, on_behalf_of=on_behalf_of)
137171

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

141-
logger.info("Starting auth-proxy sidecar on %s:%s -> %s (principal=service:%s)", host, port, base_url, principal)
175+
logger.info(
176+
"Starting auth-proxy sidecar on %s:%s -> %s (principal=service:%s, delegated=%s)",
177+
host,
178+
port,
179+
base_url,
180+
principal,
181+
on_behalf_of is not None,
182+
)
142183
if parent_stop_signal is None:
143184
server.run()
144185
return

packages/nmp_common/tests/auth/test_workload_proxy.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,88 @@ def test_forward_stamps_service_principal_and_preserves_path() -> None:
3737
assert "authorization" not in {k.lower() for k in sent.headers}
3838

3939

40+
@respx.mock
41+
def test_forward_stamps_on_behalf_of_when_configured() -> None:
42+
upstream = "http://nemo-platform-api:8080"
43+
route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={}))
44+
app = build_app(base_url=upstream, principal="agents", on_behalf_of="user:alice")
45+
client = TestClient(app)
46+
47+
client.get("/apis/entities/v2/workspaces")
48+
49+
sent = route.calls.last.request
50+
# Service principal clears the route gate; on-behalf-of narrows access to the creator.
51+
assert sent.headers["x-nmp-principal-id"] == "service:agents"
52+
assert sent.headers["x-nmp-principal-on-behalf-of"] == "user:alice"
53+
54+
55+
@respx.mock
56+
def test_forward_omits_on_behalf_of_when_not_configured() -> None:
57+
upstream = "http://nemo-platform-api:8080"
58+
route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={}))
59+
app = build_app(base_url=upstream, principal="agents")
60+
client = TestClient(app)
61+
62+
client.get("/apis/entities/v2/workspaces")
63+
64+
sent = route.calls.last.request
65+
assert "x-nmp-principal-on-behalf-of" not in {k.lower() for k in sent.headers}
66+
67+
68+
@respx.mock
69+
def test_forward_strips_inbound_on_behalf_of_to_prevent_spoofing() -> None:
70+
upstream = "http://nemo-platform-api:8080"
71+
route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={}))
72+
# The delegated identity is baked in at deploy time; a co-located workload must
73+
# not be able to override it (or inject one when none is configured) via headers.
74+
app = build_app(base_url=upstream, principal="agents", on_behalf_of="user:alice")
75+
client = TestClient(app)
76+
77+
client.get(
78+
"/apis/entities/v2/workspaces",
79+
headers={
80+
"x-nmp-principal-id": "service:platform",
81+
"x-nmp-principal-on-behalf-of": "user:attacker",
82+
# Companion metadata must not be smuggled onto our stamped OBO id:
83+
# the platform derives effective groups/email from these and feeds
84+
# them to the PDP, so attacker-chosen values would escalate.
85+
"x-nmp-principal-on-behalf-of-email": "attacker@evil.test",
86+
"x-nmp-principal-on-behalf-of-groups": "platform-admins",
87+
},
88+
)
89+
90+
sent = route.calls.last.request
91+
sent_keys = {k.lower() for k in sent.headers}
92+
assert sent.headers["x-nmp-principal-id"] == "service:agents"
93+
assert sent.headers["x-nmp-principal-on-behalf-of"] == "user:alice"
94+
# The inbound companion headers are dropped (we stamp only the OBO id).
95+
assert "x-nmp-principal-on-behalf-of-email" not in sent_keys
96+
assert "x-nmp-principal-on-behalf-of-groups" not in sent_keys
97+
98+
99+
@respx.mock
100+
def test_forward_strips_inbound_on_behalf_of_when_none_configured() -> None:
101+
upstream = "http://nemo-platform-api:8080"
102+
route = respx.get(f"{upstream}/apis/entities/v2/workspaces").mock(return_value=httpx.Response(200, json={}))
103+
app = build_app(base_url=upstream, principal="agents")
104+
client = TestClient(app)
105+
106+
client.get(
107+
"/apis/entities/v2/workspaces",
108+
headers={
109+
"x-nmp-principal-on-behalf-of": "user:attacker",
110+
"x-nmp-principal-on-behalf-of-email": "attacker@evil.test",
111+
"x-nmp-principal-on-behalf-of-groups": "platform-admins",
112+
},
113+
)
114+
115+
sent = route.calls.last.request
116+
sent_keys = {k.lower() for k in sent.headers}
117+
assert "x-nmp-principal-on-behalf-of" not in sent_keys
118+
assert "x-nmp-principal-on-behalf-of-email" not in sent_keys
119+
assert "x-nmp-principal-on-behalf-of-groups" not in sent_keys
120+
121+
40122
@respx.mock
41123
def test_forward_normalizes_bare_principal_name() -> None:
42124
upstream = "http://nemo-platform-api:8080"

plugins/nemo-agents/src/nemo_agents_plugin/runner/backend.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,16 @@ async def create_deployment(
9595
*,
9696
image: str | None = None,
9797
deployment_mode: DeploymentMode = "subprocess",
98+
created_by: str | None = None,
9899
) -> DeploymentInfo:
99-
"""Start the agent process; returns status="starting"."""
100+
"""Start the agent process; returns status="starting".
101+
102+
``created_by`` is the principal id that created the deployment. When
103+
platform auth is enabled, container-mode backends delegate the deployed
104+
agent's platform calls to this principal (on-behalf-of) so its access is
105+
scoped to what the creator can reach rather than the agents service
106+
principal's full reach.
107+
"""
100108
...
101109

102110
@abstractmethod

plugins/nemo-agents/src/nemo_agents_plugin/runner/controller.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ async def _start_deployment(self, dep: AgentDeployment) -> None:
188188
port=port,
189189
image=dep.image or None,
190190
deployment_mode=dep.deployment_mode,
191+
created_by=dep.created_by,
191192
)
192193
except Exception as exc:
193194
logger.exception("Failed to start agent for deployment '%s'", dep.name)

plugins/nemo-agents/src/nemo_agents_plugin/runner/deployments_backend.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ def build_deployment_config(
284284
plugin_wheels_init_image: str | None = None,
285285
labels: dict[str, str] | None = None,
286286
auth_proxy_identity: str | None = None,
287+
auth_proxy_on_behalf_of: str | None = None,
287288
) -> DeploymentConfig:
288289
"""Compile an agent into a long-running ``DeploymentConfig`` (Always).
289290
@@ -407,6 +408,7 @@ def build_deployment_config(
407408
"restart_policy": "Always",
408409
"auth_proxy_sidecar": auth_proxy_identity is not None,
409410
"auth_proxy_sidecar_identity": auth_proxy_identity,
411+
"auth_proxy_sidecar_on_behalf_of": auth_proxy_on_behalf_of,
410412
}
411413
)
412414

@@ -433,6 +435,7 @@ async def create_deployment(
433435
*,
434436
image: str | None = None,
435437
deployment_mode: DeploymentMode = "docker",
438+
created_by: str | None = None,
436439
) -> DeploymentInfo:
437440
"""Create DeploymentConfig + Deployment entities for the agent container."""
438441
del port # Host port is allocated by the deployments executor, not agents.
@@ -472,10 +475,24 @@ async def create_deployment(
472475
# deployments plugin compiles the sidecar from the auth_proxy flags). The
473476
# agent targets the sidecar on localhost; the sidecar forwards to the
474477
# platform with a service-principal identity header.
478+
#
479+
# The sidecar also delegates to the deployment's creator via on-behalf-of
480+
# (when known) so the running agent's platform access is scoped to what the
481+
# creator can reach — the workspace(s) they have access to — rather than the
482+
# agents service principal's full (ServiceSystem) reach.
475483
auth_proxy_identity: str | None = None
484+
auth_proxy_on_behalf_of: str | None = None
476485
is_fabric = _is_fabric_agent_config(config)
477486
if platform_auth_enabled():
478487
auth_proxy_identity = _AUTH_PROXY_IDENTITY
488+
auth_proxy_on_behalf_of = created_by or None
489+
if not auth_proxy_on_behalf_of:
490+
logger.warning(
491+
"Deployment %r has no creator principal; the agent will run as the "
492+
"unscoped %s service principal without on-behalf-of delegation.",
493+
name,
494+
_AUTH_PROXY_IDENTITY,
495+
)
479496
rewrite_target = f"http://127.0.0.1:{auth_proxy_port()}"
480497
else:
481498
rewrite_target = gateway
@@ -504,6 +521,7 @@ async def create_deployment(
504521
plugin_wheels_init_image=self._config.plugin_wheels_init_image,
505522
labels=deployment_labels,
506523
auth_proxy_identity=auth_proxy_identity,
524+
auth_proxy_on_behalf_of=auth_proxy_on_behalf_of,
507525
)
508526
await entities.create(deployment_config)
509527
try:

plugins/nemo-agents/src/nemo_agents_plugin/runner/in_memory.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,13 @@ async def create_deployment(
219219
*,
220220
image: str | None = None,
221221
deployment_mode: DeploymentMode = "subprocess",
222+
created_by: str | None = None,
222223
) -> DeploymentInfo:
223224
"""Start a local deployment for NAT workflows or Platform-owned agent specs."""
224-
del image, deployment_mode
225+
# created_by drives on-behalf-of delegation only for container modes (via
226+
# the auth-proxy sidecar). Subprocess deployments run in-process on the
227+
# platform host and do not use the sidecar, so it does not apply here.
228+
del image, deployment_mode, created_by
225229
if config.get("config_format") == NEMO_AGENTS_SPEC_CONFIG_FORMAT:
226230
return await self._create_fabric_deployment(workspace, name, config, port)
227231

plugins/nemo-agents/tests/unit/test_runner_deployments.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,12 @@ async def test_create_deployment_k8s_auth_on_requests_auth_proxy_sidecar() -> No
485485
patch("nemo_agents_plugin.runner.deployments_backend.auth_proxy_port", return_value=8090),
486486
):
487487
info = await backend.create_deployment(
488-
workspace="default", name="hello-dep", config=config, port=0, deployment_mode="k8s"
488+
workspace="default",
489+
name="hello-dep",
490+
config=config,
491+
port=0,
492+
deployment_mode="k8s",
493+
created_by="user:alice",
489494
)
490495
assert info.status == "starting"
491496
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
494499
# agents layer does not build the container itself.
495500
assert created_config.auth_proxy_sidecar is True
496501
assert created_config.auth_proxy_sidecar_identity == "agents"
502+
# The creator principal is delegated via on-behalf-of so the running agent's
503+
# platform access is scoped to what the creator can reach.
504+
assert created_config.auth_proxy_sidecar_on_behalf_of == "user:alice"
497505
assert [c.name for c in created_config.containers] == ["agent"]
498506
assert [c.name for c in created_config.init_containers] == []
499507

@@ -504,6 +512,32 @@ async def test_create_deployment_k8s_auth_on_requests_auth_proxy_sidecar() -> No
504512
)
505513

506514

515+
@pytest.mark.asyncio
516+
async def test_create_deployment_k8s_auth_on_without_creator_omits_on_behalf_of() -> None:
517+
# When auth is on but the deployment has no known creator, the sidecar still
518+
# stamps the service principal but cannot delegate — access is unscoped.
519+
backend = _backend(
520+
default_image="nmp-api:latest",
521+
default_executor="k8s",
522+
k8s_internal_base_url="http://nmp-api:8080",
523+
)
524+
entities = AsyncMock()
525+
backend._entities = entities
526+
with (
527+
patch("nemo_agents_plugin.runner.deployments_backend.get_base_url", return_value="http://localhost:8080"),
528+
patch("nemo_agents_plugin.runner.deployments_backend.platform_auth_enabled", return_value=True),
529+
patch("nemo_agents_plugin.runner.deployments_backend.auth_proxy_port", return_value=8090),
530+
):
531+
info = await backend.create_deployment(
532+
workspace="default", name="hello-dep", config={}, port=0, deployment_mode="k8s"
533+
)
534+
assert info.status == "starting"
535+
created_config = entities.create.await_args_list[0].args[0]
536+
assert created_config.auth_proxy_sidecar is True
537+
assert created_config.auth_proxy_sidecar_identity == "agents"
538+
assert created_config.auth_proxy_sidecar_on_behalf_of is None
539+
540+
507541
@pytest.mark.asyncio
508542
async def test_create_deployment_k8s_auth_off_no_sidecar() -> None:
509543
backend = _backend(

plugins/nemo-deployments/openapi/openapi.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)