From eb1e07a8239dc8b848c4f1a16b850f1b9e8022be Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Fri, 12 Jun 2026 16:12:56 +0000 Subject: [PATCH 1/2] fix: Add missing PDP authorization contributions for plugin-mounted service routes Signed-off-by: Matt Kornfield --- .../src/nemo_platform_plugin/authz.py | 61 ++++++++++++++ .../nemo_platform_plugin/tests/test_authz.py | 69 +++++++++++++++- .../src/nemo_anonymizer_plugin/service.py | 21 +++++ .../nemo-auditor/src/nemo_auditor/service.py | 50 ++++++++++++ .../src/nemo_data_designer_plugin/service.py | 21 +++++ .../src/nemo_evaluator/service.py | 26 ++++++ .../nemo_safe_synthesizer_plugin/service.py | 79 +++++-------------- 7 files changed, 266 insertions(+), 61 deletions(-) diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/authz.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/authz.py index c7eb839377..0a146c75b2 100644 --- a/packages/nemo_platform_plugin/src/nemo_platform_plugin/authz.py +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/authz.py @@ -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", @@ -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), + ), + }, } if include_healthz: if healthz_suffix is None: @@ -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//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() diff --git a/packages/nemo_platform_plugin/tests/test_authz.py b/packages/nemo_platform_plugin/tests/test_authz.py index 7829522ba8..6f43fcd490 100644 --- a/packages/nemo_platform_plugin/tests/test_authz.py +++ b/packages/nemo_platform_plugin/tests/test_authz.py @@ -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, @@ -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 @@ -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.""" @@ -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) @@ -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) @@ -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}") diff --git a/plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/service.py b/plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/service.py index a61a34d75c..ecade31a85 100644 --- a/plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/service.py +++ b/plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/service.py @@ -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 @@ -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 diff --git a/plugins/nemo-auditor/src/nemo_auditor/service.py b/plugins/nemo-auditor/src/nemo_auditor/service.py index 889d024143..a08d70e898 100644 --- a/plugins/nemo-auditor/src/nemo_auditor/service.py +++ b/plugins/nemo-auditor/src/nemo_auditor/service.py @@ -8,8 +8,12 @@ from typing import ClassVar from fastapi import APIRouter +from nemo_platform_plugin.authz import AuthzContribution, AuthzEndpointMethod from nemo_platform_plugin.service import NemoService, RouterSpec +_READ_SCOPES = ["auditor:read", "platform:read"] +_WRITE_SCOPES = ["auditor:write", "platform:write"] + class AuditorPluginService(NemoService): """Auditor plugin service. Exposes healthz and CRUD over audit configs/targets.""" @@ -17,6 +21,52 @@ class AuditorPluginService(NemoService): name: ClassVar[str] = "auditor" dependencies: ClassVar[list[str]] = ["entities"] + @classmethod + def get_authz_contribution(cls) -> AuthzContribution: + base = f"/apis/{cls.name}/v2/workspaces/{{workspace}}" + endpoints: dict[str, dict[str, AuthzEndpointMethod]] = { + f"/apis/{cls.name}/v1/healthz": { + "get": AuthzEndpointMethod(permissions=[], scopes=[]), + }, + } + permissions: dict[str, str] = {} + for resource in ("configs", "targets"): + permissions.update( + { + f"{cls.name}.{resource}.create": f"Create auditor {resource}", + f"{cls.name}.{resource}.list": f"List auditor {resource}", + f"{cls.name}.{resource}.read": f"Read auditor {resource}", + f"{cls.name}.{resource}.update": f"Update auditor {resource}", + f"{cls.name}.{resource}.delete": f"Delete auditor {resource}", + } + ) + endpoints[f"{base}/{resource}"] = { + "post": AuthzEndpointMethod( + permissions=[f"{cls.name}.{resource}.create"], + scopes=_WRITE_SCOPES, + ), + "get": AuthzEndpointMethod( + permissions=[f"{cls.name}.{resource}.list"], + scopes=_READ_SCOPES, + ), + } + endpoints[f"{base}/{resource}/{{name}}"] = { + "get": AuthzEndpointMethod( + permissions=[f"{cls.name}.{resource}.read"], + scopes=_READ_SCOPES, + ), + "put": AuthzEndpointMethod( + permissions=[f"{cls.name}.{resource}.update"], + scopes=_WRITE_SCOPES, + ), + "delete": AuthzEndpointMethod( + permissions=[f"{cls.name}.{resource}.delete"], + scopes=_WRITE_SCOPES, + ), + } + + return AuthzContribution(permissions=permissions, endpoints=endpoints) + def get_routers(self) -> list[RouterSpec]: from nemo_auditor.api.v2 import configs, targets diff --git a/plugins/nemo-data-designer/src/nemo_data_designer_plugin/service.py b/plugins/nemo-data-designer/src/nemo_data_designer_plugin/service.py index 2b85f44bdc..d68a9f6267 100644 --- a/plugins/nemo-data-designer/src/nemo_data_designer_plugin/service.py +++ b/plugins/nemo-data-designer/src/nemo_data_designer_plugin/service.py @@ -11,6 +11,12 @@ from data_designer.errors import DataDesignerError from data_designer_nemo.errors import NDDInternalError, NDDInvalidConfigError from fastapi import Request +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 @@ -23,6 +29,21 @@ class DataDesignerService(NemoService): name: ClassVar[str] = "data-designer" dependencies: ClassVar[list[str]] = ["entities", "auth", "jobs", "secrets", "files", "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/create", + permission_prefix=f"{cls.name}.jobs", + ), + ) + def get_routers(self) -> list[RouterSpec]: from nemo_data_designer_plugin.functions.preview import PreviewFunction from nemo_data_designer_plugin.jobs.create import CreateJob diff --git a/plugins/nemo-evaluator/src/nemo_evaluator/service.py b/plugins/nemo-evaluator/src/nemo_evaluator/service.py index 3209f84fb8..206d59186e 100644 --- a/plugins/nemo-evaluator/src/nemo_evaluator/service.py +++ b/plugins/nemo-evaluator/src/nemo_evaluator/service.py @@ -11,6 +11,12 @@ from nemo_evaluator.core import say_hello from nemo_evaluator.jobs.evaluate import EvaluateJob from nemo_evaluator.schema import HelloResponse +from nemo_platform_plugin.authz import ( + AuthzContribution, + AuthzEndpointMethod, + authz_for_workspace_job_collection, + combine_authz_contributions, +) from nemo_platform_plugin.jobs.routes import add_job_routes from nemo_platform_plugin.service import NemoService, RouterSpec @@ -21,6 +27,26 @@ class EvaluatorPluginService(NemoService): name: ClassVar[str] = "evaluator" dependencies: ClassVar[list[str]] = ["nemo-evaluator-sdk"] + @classmethod + def get_authz_contribution(cls) -> AuthzContribution: + return combine_authz_contributions( + AuthzContribution( + endpoints={ + f"/apis/{cls.name}/v1/healthz": { + "get": AuthzEndpointMethod(permissions=[], scopes=[]), + }, + f"/apis/{cls.name}/v1/hello/{{name}}": { + "get": AuthzEndpointMethod(permissions=[], scopes=[]), + }, + }, + ), + authz_for_workspace_job_collection( + api_area=cls.name, + collection_suffix="/evaluate/jobs", + permission_prefix=f"{cls.name}.jobs", + ), + ) + def get_routers(self) -> list[RouterSpec]: router = APIRouter() jobs_router = add_job_routes(EvaluateJob) diff --git a/plugins/nemo-safe-synthesizer/src/nemo_safe_synthesizer_plugin/service.py b/plugins/nemo-safe-synthesizer/src/nemo_safe_synthesizer_plugin/service.py index 64af17bad7..b07daf9f10 100644 --- a/plugins/nemo-safe-synthesizer/src/nemo_safe_synthesizer_plugin/service.py +++ b/plugins/nemo-safe-synthesizer/src/nemo_safe_synthesizer_plugin/service.py @@ -8,7 +8,12 @@ from typing import ClassVar from fastapi import Request -from nemo_platform_plugin.authz import AuthzContribution, AuthzEndpointMethod +from nemo_platform_plugin.authz import ( + AuthzContribution, + AuthzEndpointMethod, + authz_for_workspace_job_collection, + combine_authz_contributions, +) from nemo_platform_plugin.service import ExceptionHandler, NemoService, RouterSpec from pydantic import ValidationError from starlette import status @@ -17,17 +22,13 @@ _SERVICE_NAME = "safe-synthesizer" _JOBS_PERMISSION_PREFIX = f"{_SERVICE_NAME}.jobs" _READ_SCOPES = [f"{_SERVICE_NAME}:read", "platform:read"] -_WRITE_SCOPES = [f"{_SERVICE_NAME}:write", "platform:write"] +_RESULT_DOWNLOAD_ALIASES = ("adapter", "evaluation-report", "summary", "synthetic-data") def _read_method(permission: str) -> AuthzEndpointMethod: return AuthzEndpointMethod(permissions=[permission], scopes=list(_READ_SCOPES)) -def _write_method(permission: str) -> AuthzEndpointMethod: - return AuthzEndpointMethod(permissions=[permission], scopes=list(_WRITE_SCOPES)) - - class SafeSynthesizerService(NemoService): """Safe Synthesizer service exposed as an NMP plugin.""" @@ -38,60 +39,22 @@ class SafeSynthesizerService(NemoService): def get_authz_contribution(cls) -> AuthzContribution: """Authorization policy matching the pre-plugin Safe Synthesizer service.""" base = f"/apis/{cls.name}/v2/workspaces/{{workspace}}/jobs" - create = f"{_JOBS_PERMISSION_PREFIX}.create" - list_ = f"{_JOBS_PERMISSION_PREFIX}.list" read = f"{_JOBS_PERMISSION_PREFIX}.read" - delete = f"{_JOBS_PERMISSION_PREFIX}.delete" - cancel = f"{_JOBS_PERMISSION_PREFIX}.cancel" - - return AuthzContribution( - permissions={ - cancel: "Cancel safe synthesizer jobs", - create: "Create safe synthesizer jobs", - delete: "Delete safe synthesizer jobs", - list_: "List safe synthesizer jobs", - read: "Read safe synthesizer jobs", - }, - endpoints={ - base: { - "get": _read_method(list_), - "post": _write_method(create), - }, - f"{base}/{{job}}/results/adapter/download": { - "get": _read_method(read), - }, - f"{base}/{{job}}/results/evaluation-report/download": { - "get": _read_method(read), - }, - f"{base}/{{job}}/results/summary/download": { - "get": _read_method(read), - }, - f"{base}/{{job}}/results/synthetic-data/download": { - "get": _read_method(read), - }, - f"{base}/{{job}}/results/{{name}}": { - "get": _read_method(read), - }, - f"{base}/{{job}}/results/{{name}}/download": { - "get": _read_method(read), - }, - f"{base}/{{name}}": { - "delete": _write_method(delete), - "get": _read_method(read), - }, - f"{base}/{{name}}/cancel": { - "post": _write_method(cancel), - }, - f"{base}/{{name}}/logs": { - "get": _read_method(read), - }, - f"{base}/{{name}}/results": { - "get": _read_method(read), - }, - f"{base}/{{name}}/status": { - "get": _read_method(read), + + return combine_authz_contributions( + authz_for_workspace_job_collection( + api_area=cls.name, + collection_suffix="/jobs", + permission_prefix=_JOBS_PERMISSION_PREFIX, + ), + AuthzContribution( + endpoints={ + f"{base}/{{job}}/results/{name}/download": { + "get": _read_method(read), + } + for name in _RESULT_DOWNLOAD_ALIASES }, - }, + ), ) def get_routers(self) -> list[RouterSpec]: From 2e2da8fee2d113de5f33887cccc8d2eb62dabb97 Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Fri, 12 Jun 2026 16:50:36 +0000 Subject: [PATCH 2/2] cr feedback and some pdp fixes Signed-off-by: Matt Kornfield --- docs/set-up/config-reference.mdx | 4 +- .../src/nemo_agents_plugin/service.py | 44 +++++++------------ .../nemo-agents/tests/unit/test_service.py | 4 ++ .../nmp/core/auth/app/embedded_pdp/engine.py | 34 +++++++------- .../core/auth/src/nmp/core/auth/config.py | 6 +-- 5 files changed, 45 insertions(+), 47 deletions(-) diff --git a/docs/set-up/config-reference.mdx b/docs/set-up/config-reference.mdx index bbd4cd1d95..b4c32ba8d5 100644 --- a/docs/set-up/config-reference.mdx +++ b/docs/set-up/config-reference.mdx @@ -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 ``` diff --git a/plugins/nemo-agents/src/nemo_agents_plugin/service.py b/plugins/nemo-agents/src/nemo_agents_plugin/service.py index d8b9a8415f..a01d0ab183 100644 --- a/plugins/nemo-agents/src/nemo_agents_plugin/service.py +++ b/plugins/nemo-agents/src/nemo_agents_plugin/service.py @@ -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 { @@ -73,29 +77,25 @@ 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"): @@ -103,31 +103,21 @@ def get_authz_contribution(cls) -> AuthzContribution: 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), } ) diff --git a/plugins/nemo-agents/tests/unit/test_service.py b/plugins/nemo-agents/tests/unit/test_service.py index 45dc34a31d..b1ad121fc2 100644 --- a/plugins/nemo-agents/tests/unit/test_service.py +++ b/plugins/nemo-agents/tests/unit/test_service.py @@ -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 diff --git a/services/core/auth/src/nmp/core/auth/app/embedded_pdp/engine.py b/services/core/auth/src/nmp/core/auth/app/embedded_pdp/engine.py index aadedd4391..0d671814bf 100644 --- a/services/core/auth/src/nmp/core/auth/app/embedded_pdp/engine.py +++ b/services/core/auth/src/nmp/core/auth/app/embedded_pdp/engine.py @@ -7,7 +7,7 @@ import logging import threading from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast from wasmtime import Config, Engine, Func, FuncType, Instance, Limits, Memory, MemoryType, Module, Store, ValType @@ -26,7 +26,7 @@ class PolicyEngineError(Exception): class OPAPolicy: """Wrapper for OPA WASM policy evaluation.""" - def __init__(self, wasm_path: str, *, fuel_limit: int = 100_000_000, memory_limit_mb: int = 32): + def __init__(self, wasm_path: str, *, fuel_limit: int = 200_000_000, memory_limit_mb: int = 32): config = Config() config.consume_fuel = True engine = Engine(config) @@ -70,20 +70,24 @@ def __init__(self, wasm_path: str, *, fuel_limit: int = 100_000_000, memory_limi self.instance = Instance(self.store, module, imports) self.exports = self.instance.exports(self.store) - self._base_heap = self.exports["opa_heap_ptr_get"](self.store) + self._base_heap = self._export_func("opa_heap_ptr_get")(self.store) + self._data_heap = self._base_heap self._data_addr: Optional[int] = None self._lock = threading.Lock() + def _export_func(self, name: str) -> Func: + return cast(Func, self.exports[name]) + def _write_json(self, data: Any) -> int: """Write JSON to WASM memory, return OPA value address.""" json_bytes = json.dumps(data).encode("utf-8") - addr = self.exports["opa_malloc"](self.store, len(json_bytes)) + addr = self._export_func("opa_malloc")(self.store, len(json_bytes)) self.memory.write(self.store, json_bytes, addr) - return self.exports["opa_json_parse"](self.store, addr, len(json_bytes)) + return self._export_func("opa_json_parse")(self.store, addr, len(json_bytes)) def _read_json(self, addr: int) -> Any: """Read OPA value as JSON from WASM memory.""" - json_addr = self.exports["opa_json_dump"](self.store, addr) + json_addr = self._export_func("opa_json_dump")(self.store, addr) mem = self.memory.data_ptr(self.store) end = json_addr while mem[end] != 0: @@ -94,9 +98,9 @@ def set_data(self, data: Dict[str, Any]) -> None: """Set the base data document.""" with self._lock: self.store.set_fuel(DATA_LOADING_FUEL) - self.exports["opa_heap_ptr_set"](self.store, self._base_heap) + self._export_func("opa_heap_ptr_set")(self.store, self._base_heap) self._data_addr = self._write_json(data) - self._data_heap = self.exports["opa_heap_ptr_get"](self.store) + self._data_heap = self._export_func("opa_heap_ptr_get")(self.store) def evaluate(self, input_data: Dict[str, Any], entrypoint: int = 0) -> Any: """Evaluate policy with given input.""" @@ -107,15 +111,15 @@ def evaluate(self, input_data: Dict[str, Any], entrypoint: int = 0) -> Any: self.store.set_fuel(self.fuel_limit) heap_base = getattr(self, "_data_heap", self._base_heap) - self.exports["opa_heap_ptr_set"](self.store, heap_base) + self._export_func("opa_heap_ptr_set")(self.store, heap_base) - ctx = self.exports["opa_eval_ctx_new"](self.store) - self.exports["opa_eval_ctx_set_input"](self.store, ctx, self._write_json(input_data)) - self.exports["opa_eval_ctx_set_data"](self.store, ctx, self._data_addr) - self.exports["opa_eval_ctx_set_entrypoint"](self.store, ctx, entrypoint) + ctx = self._export_func("opa_eval_ctx_new")(self.store) + self._export_func("opa_eval_ctx_set_input")(self.store, ctx, self._write_json(input_data)) + self._export_func("opa_eval_ctx_set_data")(self.store, ctx, self._data_addr) + self._export_func("opa_eval_ctx_set_entrypoint")(self.store, ctx, entrypoint) - self.exports["eval"](self.store, ctx) - return self._read_json(self.exports["opa_eval_ctx_get_result"](self.store, ctx)) + self._export_func("eval")(self.store, ctx) + return self._read_json(self._export_func("opa_eval_ctx_get_result")(self.store, ctx)) # Module-level singleton diff --git a/services/core/auth/src/nmp/core/auth/config.py b/services/core/auth/src/nmp/core/auth/config.py index 32db3f0ba9..f10769f9ab 100644 --- a/services/core/auth/src/nmp/core/auth/config.py +++ b/services/core/auth/src/nmp/core/auth/config.py @@ -9,7 +9,7 @@ from pydantic import Field -class AuthServiceConfig(SharedAuthConfig): # type: ignore +class AuthServiceConfig(SharedAuthConfig): """ Configuration for the Auth Service. @@ -49,9 +49,9 @@ class AuthServiceConfig(SharedAuthConfig): # type: ignore # Embedded PDP resource limits embedded_pdp_cpu_limit: int = Field( - default=100, + default=200, description="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 of 200 provides headroom for full plugin-merged authorization data.", ) embedded_pdp_memory_limit_mb: int = Field( default=32,