Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions docs/set-up/config-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ auth:
admin_email:
# Name of the default workspace where all authenticated users get Editor role | default: 'default'
default_workspace: default
# CPU budget for embedded PDP policy evaluation, in millions of WASM fuel units. Typical evaluations use 20-25; default of 100 provides ~4x headroom. | default: 100
embedded_pdp_cpu_limit: 100
# CPU budget for embedded PDP policy evaluation, in millions of WASM fuel units. Default of 200 provides headroom for full plugin-merged authorization data. | default: 200
embedded_pdp_cpu_limit: 200
# Maximum linear memory (MB) the embedded PDP WASM runtime can consume. | default: 32
embedded_pdp_memory_limit_mb: 32
```
Expand Down
61 changes: 61 additions & 0 deletions packages/nemo_platform_plugin/src/nemo_platform_plugin/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def _scopes_for(api_area: str, write: bool) -> list[str]:

def _job_collection_permissions(permission_prefix: str) -> dict[str, str]:
return {
f"{permission_prefix}.cancel": f"Cancel {permission_prefix} jobs",
f"{permission_prefix}.create": f"Create {permission_prefix} jobs",
f"{permission_prefix}.list": f"List {permission_prefix} jobs",
f"{permission_prefix}.read": f"Read {permission_prefix} jobs",
Expand Down Expand Up @@ -129,6 +130,42 @@ def authz_for_workspace_job_collection(
scopes=_scopes_for(api_area, write=True),
),
},
f"{base}/{{name}}/cancel": {
"post": AuthzEndpointMethod(
permissions=[f"{prefix}.cancel"],
scopes=_scopes_for(api_area, write=True),
),
},
f"{base}/{{name}}/logs": {
"get": AuthzEndpointMethod(
permissions=[f"{prefix}.read"],
scopes=_scopes_for(api_area, write=False),
),
},
f"{base}/{{name}}/results": {
"get": AuthzEndpointMethod(
permissions=[f"{prefix}.read"],
scopes=_scopes_for(api_area, write=False),
),
},
f"{base}/{{name}}/status": {
"get": AuthzEndpointMethod(
permissions=[f"{prefix}.read"],
scopes=_scopes_for(api_area, write=False),
),
},
f"{base}/{{job}}/results/{{name}}": {
"get": AuthzEndpointMethod(
permissions=[f"{prefix}.read"],
scopes=_scopes_for(api_area, write=False),
),
},
f"{base}/{{job}}/results/{{name}}/download": {
"get": AuthzEndpointMethod(
permissions=[f"{prefix}.read"],
scopes=_scopes_for(api_area, write=False),
),
},
Comment thread
mckornfield marked this conversation as resolved.
}
if include_healthz:
if healthz_suffix is None:
Expand All @@ -144,6 +181,30 @@ def authz_for_workspace_job_collection(
return AuthzContribution(permissions=perms, endpoints=endpoints)


def authz_for_workspace_function(
api_area: str,
function_suffix: str,
permission_prefix: str,
*,
read_only: bool = False,
) -> AuthzContribution:
"""Build authz for one standard function route under ``/apis/<area>/v2/workspaces/{workspace}``."""
if not function_suffix.startswith("/"):
raise ValueError("function_suffix must start with '/'")
permission = f"{permission_prefix}.exec"
return AuthzContribution(
permissions={permission: f"Execute {permission_prefix} function"},
endpoints={
f"/apis/{api_area}/v2/workspaces/{{workspace}}{function_suffix}": {
"post": AuthzEndpointMethod(
permissions=[permission],
scopes=_scopes_for(api_area, write=not read_only),
),
}
},
)


def combine_authz_contributions(*contribs: AuthzContribution) -> AuthzContribution:
"""Merge multiple :class:`AuthzContribution` objects into one (e.g. hub + backends)."""
merged = AuthzContribution()
Expand Down
69 changes: 66 additions & 3 deletions packages/nemo_platform_plugin/tests/test_authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock

import httpx
import pytest
import yaml
from fastapi.routing import APIRoute
from nemo_platform_plugin.authz import (
AuthzContribution,
AuthzEndpointMethod,
Expand All @@ -18,6 +21,9 @@
_collect_from_plugin_surface,
discover_authz_contributions,
)
from nemo_platform_plugin.authz_format import validate_static_authz_data
from nemo_platform_plugin.authz_merge import merge_authz_contributions
from nemo_platform_plugin.discovery import discover_services
from nemo_platform_plugin.job import NemoJob
from nemo_platform_plugin.scheduler import NemoJobScheduler
from nemo_platform_plugin.service import NemoService
Expand Down Expand Up @@ -53,6 +59,17 @@ def test_authz_for_workspace_job_collection_paths() -> None:
assert "customization:write" in (post.scopes or [])
assert "customization.automodel.jobs.create" in contrib.permissions

endpoints = contrib.endpoints
cancel = endpoints["/apis/customization/v2/workspaces/{workspace}/automodel/jobs/{name}/cancel"]["post"]
status = endpoints["/apis/customization/v2/workspaces/{workspace}/automodel/jobs/{name}/status"]["get"]
download = endpoints["/apis/customization/v2/workspaces/{workspace}/automodel/jobs/{job}/results/{name}/download"][
"get"
]
assert cancel.permissions == ["customization.automodel.jobs.cancel"]
assert status.permissions == ["customization.automodel.jobs.read"]
assert download.permissions == ["customization.automodel.jobs.read"]
assert "customization.automodel.jobs.cancel" in contrib.permissions


def test_service_class_get_authz_contribution_without_instance() -> None:
"""discover_services yields classes; get_authz_contribution must be a classmethod."""
Expand Down Expand Up @@ -118,7 +135,9 @@ def get_authz_contribution(cls) -> AuthzContribution:
},
)
backend_parts = [
contributor.get_authz_contribution() for contributor in discover_customization_contributors().values()
contribution
for contributor in discover_customization_contributors().values()
if isinstance((contribution := contributor.get_authz_contribution()), AuthzContribution)
]
return combine_authz_contributions(hub, *backend_parts)

Expand Down Expand Up @@ -177,7 +196,7 @@ def test_nemo_authz_entry_point_discovered(monkeypatch: pytest.MonkeyPatch) -> N

def test_submit_remote_forwards_authorization_header() -> None:
"""Authenticated CLI submit passes Authorization to the protected job route."""
capture: dict[str, object] = {}
capture: dict[str, dict[str, str]] = {}

def handler(request: httpx.Request) -> httpx.Response:
capture["headers"] = dict(request.headers)
Expand All @@ -197,5 +216,49 @@ def handler(request: httpx.Request) -> httpx.Response:

assert result == {"id": "job-123", "status": "queued"}
headers = capture["headers"]
assert isinstance(headers, dict)
assert headers.get("authorization") == "Bearer test-token"


def test_discovered_service_routes_have_authz_entries() -> None:
static_path = Path(__file__).resolve().parents[3] / "services/core/auth/src/nmp/core/auth/assets/static-authz.yaml"
with static_path.open() as f:
static_authz = yaml.safe_load(f)

discover_authz_contributions.cache_clear()
try:
contributions = discover_authz_contributions()
finally:
discover_authz_contributions.cache_clear()

merged = merge_authz_contributions(static_authz, [contribution.to_dict() for contribution in contributions])
validate_static_authz_data(merged)
endpoints = merged["authz"]["endpoints"]

missing = sorted(
f"{method.upper()} {path}"
for path, method in _discovered_service_route_methods()
if method not in endpoints.get(path, {})
)

assert missing == []


def _discovered_service_route_methods() -> set[tuple[str, str]]:
route_methods: set[tuple[str, str]] = set()
for service_name, service_cls in discover_services().items():
service = service_cls()
for spec in service.get_routers():
prefix = f"/apis/{service_name}{spec.prefix}".rstrip("/")
for route in spec.router.routes:
if not isinstance(route, APIRoute):
continue
path = f"{prefix}{route.path}"
normalized_path = _normalize_route_path(path)
for method in route.methods or set():
route_methods.add((normalized_path, method.lower()))
return route_methods


def _normalize_route_path(path: str) -> str:
# Authz policy uses placeholder names only; FastAPI route converters are an implementation detail.
return path.replace("{trailing_uri:path}", "{trailing_uri}")
44 changes: 17 additions & 27 deletions plugins/nemo-agents/src/nemo_agents_plugin/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def _write_method(permission: str) -> AuthzEndpointMethod:
return AuthzEndpointMethod(permissions=[permission], scopes=list(_WRITE_SCOPES))


def _read_methods(permission: str) -> dict[str, AuthzEndpointMethod]:
return {method: _read_method(permission) for method in ("get", "head")}


def _gateway_methods(permission: str) -> dict[str, AuthzEndpointMethod]:
read_methods = {"get", "head", "options"}
return {
Expand Down Expand Up @@ -73,61 +77,47 @@ def get_authz_contribution(cls) -> AuthzContribution:

endpoints: dict[str, dict[str, AuthzEndpointMethod]] = {
f"{base}/agents": {
"get": _read_method(agent_list),
**_read_methods(agent_list),
"post": _write_method(agent_create),
},
f"{base}/agents/{{name}}": {
"delete": _write_method(agent_delete),
"get": _read_method(agent_read),
**_read_methods(agent_read),
},
f"{base}/agents/{{name}}/-/{{trailing_uri}}": _gateway_methods(gateway_exec),
f"{base}/deployments": {
"get": _read_method(deployment_list),
**_read_methods(deployment_list),
"post": _write_method(deployment_create),
},
f"{base}/deployments/{{name}}": {
"delete": _write_method(deployment_delete),
"get": _read_method(deployment_read),
**_read_methods(deployment_read),
},
f"{base}/deployments/{{name}}/-/{{trailing_uri}}": _gateway_methods(gateway_exec),
f"{base}/deployments/{{name}}/logs": {
"get": _read_method(deployment_read),
},
f"{base}/deployments/{{name}}/logs/stream": {
"get": _read_method(deployment_read),
},
f"{base}/deployments/{{name}}/logs": _read_methods(deployment_read),
f"{base}/deployments/{{name}}/logs/stream": _read_methods(deployment_read),
}

for job_name in ("evaluate", "evaluate-suite", "optimize-skills", "analyze", "optimize"):
jobs_base = f"{base}/jobs/{job_name}"
endpoints.update(
{
jobs_base: {
"get": _read_method(job_list),
**_read_methods(job_list),
"post": _write_method(job_create),
},
f"{jobs_base}/{{name}}": {
"delete": _write_method(job_delete),
"get": _read_method(job_read),
**_read_methods(job_read),
},
f"{jobs_base}/{{name}}/cancel": {
"post": _write_method(job_cancel),
},
f"{jobs_base}/{{name}}/logs": {
"get": _read_method(job_read),
},
f"{jobs_base}/{{name}}/results": {
"get": _read_method(job_read),
},
f"{jobs_base}/{{name}}/status": {
"get": _read_method(job_read),
},
f"{jobs_base}/{{job}}/results/{{name}}": {
"get": _read_method(job_read),
},
f"{jobs_base}/{{job}}/results/{{name}}/download": {
"get": _read_method(job_read),
},
f"{jobs_base}/{{name}}/logs": _read_methods(job_read),
f"{jobs_base}/{{name}}/results": _read_methods(job_read),
f"{jobs_base}/{{name}}/status": _read_methods(job_read),
f"{jobs_base}/{{job}}/results/{{name}}": _read_methods(job_read),
f"{jobs_base}/{{job}}/results/{{name}}/download": _read_methods(job_read),
}
)

Expand Down
4 changes: 4 additions & 0 deletions plugins/nemo-agents/tests/unit/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ def test_authz_contribution_grants_studio_deployments_list_to_viewer() -> None:
deployments_path = "/apis/agents/v2/workspaces/{workspace}/deployments"
assert endpoints[deployments_path]["get"]["permissions"] == ["agents.deployments.list"]
assert endpoints[deployments_path]["get"]["scopes"] == ["agents:read", "platform:read"]
assert endpoints[deployments_path]["head"]["permissions"] == ["agents.deployments.list"]
assert endpoints[deployments_path]["head"]["scopes"] == ["agents:read", "platform:read"]
job_result_path = "/apis/agents/v2/workspaces/{workspace}/jobs/evaluate/{job}/results/{name}"
assert endpoints[job_result_path]["head"]["permissions"] == ["agents.jobs.read"]
assert "agents.deployments.list" in viewer_permissions
assert "agents.deployments.read" in viewer_permissions
assert "agents.deployments.create" in editor_permissions
Expand Down
21 changes: 21 additions & 0 deletions plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
from data_designer_nemo.errors import NDDInternalError, NDDInvalidConfigError
from fastapi import Request
from nemo_anonymizer_plugin.app.errors import AnonymizerInternalError, AnonymizerInvalidConfigError
from nemo_platform_plugin.authz import (
AuthzContribution,
authz_for_workspace_function,
authz_for_workspace_job_collection,
combine_authz_contributions,
)
from nemo_platform_plugin.service import NemoService, RouterSpec
from pydantic import ValidationError
from starlette import status
Expand All @@ -30,6 +36,21 @@ class AnonymizerService(NemoService):
"inference-gateway",
]

@classmethod
def get_authz_contribution(cls) -> AuthzContribution:
return combine_authz_contributions(
authz_for_workspace_function(
api_area=cls.name,
function_suffix="/preview",
permission_prefix=f"{cls.name}.preview",
),
authz_for_workspace_job_collection(
api_area=cls.name,
collection_suffix="/jobs/run",
permission_prefix=f"{cls.name}.jobs",
),
)

def get_routers(self) -> list[RouterSpec]:
from nemo_anonymizer_plugin.functions.preview import PreviewFunction
from nemo_anonymizer_plugin.jobs.run import RunJob
Expand Down
Loading
Loading