From 9888033c4e5dcebd72e74898517c13fce8f326cd Mon Sep 17 00:00:00 2001 From: Matt Kornfield Date: Mon, 15 Jun 2026 16:50:18 +0000 Subject: [PATCH] fix: make opa local setup more resilient Signed-off-by: Matt Kornfield --- .github/actions/build-policy-wasm/action.yaml | 8 - docs/auth/authorization/policy-engine.mdx | 7 + docs/auth/deployment/configuration.mdx | 23 ++ docs/set-up/config-reference.mdx | 2 + .../nmp_common/src/nmp/common/config/base.py | 9 + .../src/nmp/platform_runner/run.py | 23 ++ .../src/nmp/platform_runner/server.py | 18 ++ .../tests/test_run_policy_wasm.py | 38 ++++ .../nmp_platform_runner/tests/test_server.py | 55 +++++ script/build_policy_wasm.sh | 177 +++++++++++++++- .../core/auth/app/embedded_pdp/__init__.py | 3 + .../nmp/core/auth/app/embedded_pdp/engine.py | 6 +- .../core/auth/app/embedded_pdp/policy_wasm.py | 196 ++++++++++++++++++ .../core/auth/src/nmp/core/auth/service.py | 3 + services/core/auth/tests/test_config.py | 1 + services/core/auth/tests/test_policy_wasm.py | 126 +++++++++++ .../auth/tests/test_service_policy_wasm.py | 55 +++++ 17 files changed, 728 insertions(+), 22 deletions(-) create mode 100644 packages/nmp_platform_runner/tests/test_run_policy_wasm.py create mode 100644 services/core/auth/src/nmp/core/auth/app/embedded_pdp/policy_wasm.py create mode 100644 services/core/auth/tests/test_policy_wasm.py create mode 100644 services/core/auth/tests/test_service_policy_wasm.py diff --git a/.github/actions/build-policy-wasm/action.yaml b/.github/actions/build-policy-wasm/action.yaml index 56fcc94b1f..98d3219447 100644 --- a/.github/actions/build-policy-wasm/action.yaml +++ b/.github/actions/build-policy-wasm/action.yaml @@ -8,14 +8,6 @@ description: > runs: using: composite steps: - - name: Install OPA - shell: bash - env: - OPA_VERSION: v1.8.0 - run: | - curl -fsSL -o /usr/local/bin/opa \ - "https://github.com/open-policy-agent/opa/releases/download/${OPA_VERSION}/opa_linux_amd64_static" - chmod +x /usr/local/bin/opa - name: Build policy WASM shell: bash run: ./script/build_policy_wasm.sh diff --git a/docs/auth/authorization/policy-engine.mdx b/docs/auth/authorization/policy-engine.mdx index 3c8887a288..878f136f26 100644 --- a/docs/auth/authorization/policy-engine.mdx +++ b/docs/auth/authorization/policy-engine.mdx @@ -24,9 +24,15 @@ The embedded PDP uses a WASM-compiled Rego policy engine built into the auth ser ### How It Works +- Rego policy files are compiled to `policy.wasm` with `make build-policy` - Policy data (role bindings, workspace visibility, endpoint permissions) is served by the auth service - Data is refreshed on a configurable interval (default: 30 seconds) +In source checkouts, startup with `auth.enabled: true` and +`policy_decision_point_provider: embedded` automatically builds a missing or +stale `policy.wasm` using the pinned OPA version from `script/build_policy_wasm.sh`. +Packaged wheels and container images should include `policy.wasm` at build time. + ### Configuration ```yaml @@ -34,6 +40,7 @@ auth: enabled: true policy_decision_point_provider: "embedded" policy_decision_point_base_url: "http://auth:8000" + embedded_pdp_auto_build_wasm: false policy_data_refresh_interval: 30 # seconds ``` diff --git a/docs/auth/deployment/configuration.mdx b/docs/auth/deployment/configuration.mdx index 1e36421a29..f6ec04ce01 100644 --- a/docs/auth/deployment/configuration.mdx +++ b/docs/auth/deployment/configuration.mdx @@ -73,6 +73,7 @@ NMP_AUTH_ENABLED=true NMP_AUTH_POLICY_DECISION_POINT_BASE_URL=http://auth:8000 NMP_AUTH_POLICY_DECISION_POINT_PROVIDER=embedded NMP_AUTH_ADMIN_EMAIL=admin@example.com +NMP_AUTH_EMBEDDED_PDP_AUTO_BUILD_WASM=true ``` Nested keys (e.g., OIDC) use double underscore: `NMP_AUTH_OIDC__ISSUER`, `NMP_AUTH_OIDC__CLIENT_ID`. @@ -93,9 +94,30 @@ auth: enabled: true policy_decision_point_provider: embedded policy_decision_point_base_url: "http://localhost:8080" + embedded_pdp_auto_build_wasm: true admin_email: "admin@example.com" ``` +When running from a source checkout, embedded PDP startup automatically builds a +missing or stale `policy.wasm` with the pinned OPA version used by +`make build-policy`. Packaged deployments should include `policy.wasm` at image +or wheel build time; set `embedded_pdp_auto_build_wasm: false` to fail fast if +that artifact is missing. + +In offline development environments, provide the pinned OPA binary explicitly: + +```bash +OPA_BIN=/path/to/opa_linux_amd64_static uv run nemo services run --host 127.0.0.1 --port 8080 +``` + +Or seed the local cache used by `script/build_policy_wasm.sh`: + +```bash +mkdir -p .cache/opa/v1.8.0 +cp /path/to/opa_linux_amd64_static .cache/opa/v1.8.0/opa_linux_amd64_static +chmod +x .cache/opa/v1.8.0/opa_linux_amd64_static +``` + ### Production with embedded PDP ```yaml @@ -103,6 +125,7 @@ auth: enabled: true policy_decision_point_base_url: "http://auth:8000" policy_decision_point_provider: embedded + embedded_pdp_auto_build_wasm: false policy_data_refresh_interval: 30 admin_email: "platform-admin@company.com" oidc: diff --git a/docs/set-up/config-reference.mdx b/docs/set-up/config-reference.mdx index dc79ce0b2a..1dae088ec3 100644 --- a/docs/set-up/config-reference.mdx +++ b/docs/set-up/config-reference.mdx @@ -75,6 +75,8 @@ auth: propagation_poll_interval_seconds: 1.0 # Allow unsigned JWTs (`alg=none`) for local development/testing. Disabled by default and should not be enabled in production. | default: False allow_unsigned_jwt: false + # When auth is enabled with the embedded PDP and policy.wasm is missing or stale, build it automatically from a local NeMo Platform source checkout. Packaged deployments should include policy.wasm at build time and can disable this for fail-fast startup. | default: True + embedded_pdp_auto_build_wasm: true # OIDC configuration for native token validation. oidc: # Enable native OIDC token validation. | default: False diff --git a/packages/nmp_common/src/nmp/common/config/base.py b/packages/nmp_common/src/nmp/common/config/base.py index 82c6804d8e..ae7013b2b1 100644 --- a/packages/nmp_common/src/nmp/common/config/base.py +++ b/packages/nmp_common/src/nmp/common/config/base.py @@ -210,6 +210,15 @@ class AuthConfig(create_service_config_class("auth")): ), ) + embedded_pdp_auto_build_wasm: bool = Field( + default=True, + description=( + "When auth is enabled with the embedded PDP and policy.wasm is missing or stale, " + "build it automatically from a local NeMo Platform source checkout. Packaged deployments " + "should include policy.wasm at build time and can disable this for fail-fast startup." + ), + ) + oidc: OIDCConfig = Field( default_factory=OIDCConfig, description="OIDC configuration for native token validation.", diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/run.py b/packages/nmp_platform_runner/src/nmp/platform_runner/run.py index bab76c6afa..7ef71e4a15 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/run.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/run.py @@ -28,6 +28,7 @@ logger = logging.getLogger(__name__) console = Console() +error_console = Console(stderr=True) def _startup_phase(name: str, t0: float) -> None: @@ -52,6 +53,25 @@ def _database_display(db_url: str) -> str: return db_type +def _is_policy_wasm_error(error: Exception) -> bool: + return ( + error.__class__.__name__ == "PolicyWasmError" + and error.__class__.__module__ == "nmp.core.auth.app.embedded_pdp.policy_wasm" + ) + + +def _display_policy_wasm_error(error: Exception) -> None: + error_console.print() + error_console.print( + Panel( + str(error), + title="Embedded Auth Policy WASM Startup Failed", + border_style="red", + expand=False, + ) + ) + + def run_controllers_in_threads( controller_run_funcs: dict[str, Callable], stop_signal: threading.Event, @@ -163,6 +183,9 @@ def signal_handler(signum: int, _frame: object) -> None: except KeyboardInterrupt: logger.info("Received keyboard interrupt, shutting down") except Exception as error: + if _is_policy_wasm_error(error): + _display_policy_wasm_error(error) + raise SystemExit(1) from None logger.exception("Fatal error occurred") raise SystemExit(1) from error finally: diff --git a/packages/nmp_platform_runner/src/nmp/platform_runner/server.py b/packages/nmp_platform_runner/src/nmp/platform_runner/server.py index 40935c11df..a236148b24 100644 --- a/packages/nmp_platform_runner/src/nmp/platform_runner/server.py +++ b/packages/nmp_platform_runner/src/nmp/platform_runner/server.py @@ -72,6 +72,22 @@ async def dispatch(self, request: Request, call_next) -> Response: return await call_next(request) +def preflight_embedded_auth_policy_wasm(auth_config) -> None: + """Ensure local embedded auth PDP has a loadable policy.wasm before serving traffic.""" + if not auth_config.enabled or auth_config.policy_decision_point_provider != "embedded": + return + + try: + from nmp.core.auth.app.embedded_pdp.policy_wasm import ensure_embedded_policy_wasm + except ImportError as exc: + raise RuntimeError( + "Auth is enabled with the embedded PDP, but the nmp-auth package is not installed. " + "Install nmp-auth or set auth.policy_decision_point_provider='opa'." + ) from exc + + ensure_embedded_policy_wasm(auto_build=getattr(auth_config, "embedded_pdp_auto_build_wasm", True)) + + def create_platform_openapi_app() -> FastAPI: """Create the platform app used for aggregate OpenAPI generation.""" services = [] @@ -196,6 +212,7 @@ async def root_handler() -> Response: def run_server(services: list[Service] | None = None, host: str = "0.0.0.0", port: int = 8080) -> None: """Run the platform API server.""" + preflight_embedded_auth_policy_wasm(get_auth_config()) app = create_app(services or []) setup_fastapi_instrumentations(app) uvicorn.run(app, host=host, port=port, log_config=None) @@ -203,6 +220,7 @@ def run_server(services: list[Service] | None = None, host: str = "0.0.0.0", por def run_server_with_reload(app_factory: str, host: str = "0.0.0.0", port: int = 8080) -> None: """Run the platform API server with uvicorn reload enabled.""" + preflight_embedded_auth_policy_wasm(get_auth_config()) reload_dirs = [ "packages/nmp_platform/src", "services/core", diff --git a/packages/nmp_platform_runner/tests/test_run_policy_wasm.py b/packages/nmp_platform_runner/tests/test_run_policy_wasm.py new file mode 100644 index 0000000000..39661b63e7 --- /dev/null +++ b/packages/nmp_platform_runner/tests/test_run_policy_wasm.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from nmp.platform_runner import run +from rich.console import Console + + +class PolicyWasmError(Exception): + pass + + +PolicyWasmError.__module__ = "nmp.core.auth.app.embedded_pdp.policy_wasm" + + +def test_policy_wasm_error_is_expected_startup_error(): + assert run._is_policy_wasm_error(PolicyWasmError("boom")) + assert not run._is_policy_wasm_error(RuntimeError("boom")) + + +def test_policy_wasm_error_renders_as_panel(tmp_path, monkeypatch): + stderr = tmp_path / "stderr.txt" + console = Console(file=stderr.open("w"), force_terminal=False, width=100) + monkeypatch.setattr(run, "error_console", console) + + run._display_policy_wasm_error( + PolicyWasmError( + "Failed to build embedded auth PDP policy.wasm.\n\n" + "Command:\n" + " script/build_policy_wasm.sh\n\n" + "Offline options:\n" + " OPA_BIN=/path/to/opa ./script/build_policy_wasm.sh" + ) + ) + + output = stderr.read_text() + assert "Embedded Auth Policy WASM Startup Failed" in output + assert "script/build_policy_wasm.sh" in output + assert "OPA_BIN=/path/to/opa" in output diff --git a/packages/nmp_platform_runner/tests/test_server.py b/packages/nmp_platform_runner/tests/test_server.py index cf58678fdb..901d54e964 100644 --- a/packages/nmp_platform_runner/tests/test_server.py +++ b/packages/nmp_platform_runner/tests/test_server.py @@ -72,6 +72,61 @@ def fake_create_app(services, controller_run_funcs=None, _http_client=None): assert captured["controller_run_funcs"] == {"agents-deployment": plugin_controller} +def test_embedded_auth_preflight_invokes_policy_wasm_helper(monkeypatch): + calls: list[bool] = [] + auth_cfg = AuthConfig( + enabled=True, + policy_decision_point_provider="embedded", + embedded_pdp_auto_build_wasm=False, + ) + + from nmp.core.auth.app.embedded_pdp import policy_wasm + + monkeypatch.setattr(policy_wasm, "ensure_embedded_policy_wasm", lambda *, auto_build: calls.append(auto_build)) + + server.preflight_embedded_auth_policy_wasm(auth_cfg) + + assert calls == [False] + + +@pytest.mark.parametrize( + "auth_cfg", + [ + AuthConfig(enabled=False, policy_decision_point_provider="embedded"), + AuthConfig(enabled=True, policy_decision_point_provider="opa"), + ], +) +def test_embedded_auth_preflight_skips_when_not_needed(auth_cfg, monkeypatch): + calls: list[bool] = [] + + from nmp.core.auth.app.embedded_pdp import policy_wasm + + monkeypatch.setattr(policy_wasm, "ensure_embedded_policy_wasm", lambda *, auto_build: calls.append(auto_build)) + + server.preflight_embedded_auth_policy_wasm(auth_cfg) + + assert calls == [] + + +def test_run_server_runs_embedded_auth_preflight(): + auth_cfg = _make_auth_config(enabled=True) + calls: list[AuthConfig] = [] + with ( + patch("nmp.platform_runner.server.get_auth_config", return_value=auth_cfg), + patch( + "nmp.platform_runner.server.preflight_embedded_auth_policy_wasm", side_effect=lambda cfg: calls.append(cfg) + ), + patch("nmp.platform_runner.server.create_app", return_value=FastAPI()) as create_app, + patch("nmp.platform_runner.server.setup_fastapi_instrumentations"), + patch("nmp.platform_runner.server.uvicorn.run") as uvicorn_run, + ): + server.run_server(services=[], host="127.0.0.1", port=9999) + + assert calls == [auth_cfg] + create_app.assert_called_once_with([]) + uvicorn_run.assert_called_once() + + def test_create_default_app_raises_for_unknown_service_from_env(monkeypatch): monkeypatch.setattr(server, "_obs_initialized", True) monkeypatch.setenv("NMP_SERVICES", "missing-service") diff --git a/script/build_policy_wasm.sh b/script/build_policy_wasm.sh index 2387f73a05..4ee0a88a90 100755 --- a/script/build_policy_wasm.sh +++ b/script/build_policy_wasm.sh @@ -6,25 +6,176 @@ # the developer has installed locally. # # Environment variables: -# OUTPUT_DIR - Directory to write policy.wasm into. -# Default: services/core/auth/src/nmp/core/auth/assets -# REPO_ROOT - Repository root. Default: auto-detected via git. +# OUTPUT_DIR - Directory to write policy.wasm into. +# Default: services/core/auth/src/nmp/core/auth/assets +# REPO_ROOT - Repository root. Default: auto-detected via git. +# OPA_VERSION - OPA release to use. Default: v1.8.0 +# OPA_BIN - Optional explicit OPA binary path. Must match OPA_VERSION. +# OPA_CACHE_DIR - Directory for downloaded OPA binaries. Default: .cache/opa set -eu # --- Configuration --- REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel)}" +OPA_VERSION="${OPA_VERSION:-v1.8.0}" +OPA_VERSION_NO_V="${OPA_VERSION#v}" +OPA_CACHE_DIR="${OPA_CACHE_DIR:-${REPO_ROOT}/.cache/opa}" +OPA_DOWNLOAD_BASE_URL="${OPA_DOWNLOAD_BASE_URL:-https://openpolicyagent.org/downloads}" POLICY_DIR="${REPO_ROOT}/services/core/auth/src/nmp/core/auth/app/policies" OUTPUT_DIR="${OUTPUT_DIR:-${REPO_ROOT}/services/core/auth/src/nmp/core/auth/assets}" ENTRYPOINTS="-e authz/allow -e authz/has_permissions -e authz/has_role" -if ! command -v opa > /dev/null; then - echo "opa command not found. exiting.." - exit 1 -fi +detect_opa_asset() { + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + + case "${arch}" in + x86_64 | amd64) arch="amd64" ;; + aarch64 | arm64) arch="arm64" ;; + *) + echo "Unsupported architecture for OPA download: ${arch}" >&2 + exit 1 + ;; + esac + + case "${os}" in + linux | darwin) ;; + *) + echo "Unsupported OS for OPA download: ${os}" >&2 + exit 1 + ;; + esac + + echo "opa_${os}_${arch}_static" +} + +print_opa_help() { + asset="$1" + cache_dir="${OPA_CACHE_DIR}/${OPA_VERSION}" + candidate="${cache_dir}/${asset}" + + cat >&2 </dev/null | awk '/^Version:/ {print $2; exit}')" + test "${version}" = "${OPA_VERSION_NO_V}" +} + +sha256_file() { + file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${file}" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${file}" | awk '{print $1}' + else + echo "Neither sha256sum nor shasum is available for OPA checksum verification." >&2 + exit 1 + fi +} + +download_opa() { + asset="$(detect_opa_asset)" + cache_dir="${OPA_CACHE_DIR}/${OPA_VERSION}" + candidate="${cache_dir}/${asset}" + + if [ -x "${candidate}" ] && opa_version_matches "${candidate}"; then + echo "${candidate}" + return + fi + + mkdir -p "${cache_dir}" + tmp_bin="$(mktemp)" + tmp_sha="$(mktemp)" + cleanup_download() { + rm -f "${tmp_bin}" "${tmp_sha}" + } + trap cleanup_download EXIT + + url="${OPA_DOWNLOAD_BASE_URL}/${OPA_VERSION}/${asset}" + sha_url="${url}.sha256" + echo "Downloading OPA ${OPA_VERSION} from ${url}..." >&2 + if ! curl -fsSL "${url}" -o "${tmp_bin}"; then + echo "Failed to download OPA binary from ${url}." >&2 + print_opa_help "${asset}" + exit 1 + fi + if ! curl -fsSL "${sha_url}" -o "${tmp_sha}"; then + echo "Failed to download OPA checksum from ${sha_url}." >&2 + print_opa_help "${asset}" + exit 1 + fi + + expected="$(awk '{print $1; exit}' "${tmp_sha}")" + actual="$(sha256_file "${tmp_bin}")" + if [ "${expected}" != "${actual}" ]; then + echo "Checksum verification failed for ${asset}." >&2 + echo "expected: ${expected}" >&2 + echo "actual: ${actual}" >&2 + print_opa_help "${asset}" + exit 1 + fi + + chmod +x "${tmp_bin}" + mv "${tmp_bin}" "${candidate}" + trap - EXIT + rm -f "${tmp_sha}" + + echo "${candidate}" +} + +resolve_opa() { + asset="$(detect_opa_asset)" + + if [ -n "${OPA_BIN:-}" ]; then + if [ ! -x "${OPA_BIN}" ]; then + echo "OPA_BIN is not executable: ${OPA_BIN}" >&2 + print_opa_help "${asset}" + exit 1 + fi + if ! opa_version_matches "${OPA_BIN}"; then + echo "OPA_BIN must be OPA ${OPA_VERSION}; got:" >&2 + "${OPA_BIN}" version >&2 || true + print_opa_help "${asset}" + exit 1 + fi + echo "${OPA_BIN}" + return + fi + + if command -v opa >/dev/null 2>&1; then + path="$(command -v opa)" + if opa_version_matches "${path}"; then + echo "${path}" + return + fi + echo "Found ${path}, but it is not OPA ${OPA_VERSION}; using pinned cached binary." >&2 + fi + + download_opa +} + +OPA="$(resolve_opa)" echo "###############################" -opa version +"${OPA}" version echo "###############################" echo "" @@ -40,13 +191,19 @@ trap cleanup EXIT # Using relative paths (*.rego) ensures the wasm output is path-independent. # shellcheck disable=SC2086 -(cd "${POLICY_DIR}" && opa build -t wasm ${ENTRYPOINTS} -o "${BUNDLE_TMP}/bundle.tar.gz" *.rego) +(cd "${POLICY_DIR}" && "${OPA}" build -t wasm ${ENTRYPOINTS} -o "${BUNDLE_TMP}/bundle.tar.gz" *.rego) ls -1 "${BUNDLE_TMP}" # --- Extract WASM --- mkdir -p "${OUTPUT_DIR}" -tar -C "${OUTPUT_DIR}" -zxvf "${BUNDLE_TMP}/bundle.tar.gz" "/policy.wasm" +POLICY_WASM_MEMBER="$(tar -tzf "${BUNDLE_TMP}/bundle.tar.gz" | awk '$0 == "/policy.wasm" || $0 == "policy.wasm" {print; exit}')" +if [ -z "${POLICY_WASM_MEMBER}" ]; then + echo "policy.wasm not found in OPA bundle." >&2 + tar -tzf "${BUNDLE_TMP}/bundle.tar.gz" >&2 + exit 1 +fi +tar -C "${OUTPUT_DIR}" -zxvf "${BUNDLE_TMP}/bundle.tar.gz" "${POLICY_WASM_MEMBER}" echo "policy.wasm written to ${OUTPUT_DIR}/policy.wasm" ls -lh "${OUTPUT_DIR}/policy.wasm" diff --git a/services/core/auth/src/nmp/core/auth/app/embedded_pdp/__init__.py b/services/core/auth/src/nmp/core/auth/app/embedded_pdp/__init__.py index 2e5d349b14..e3734f7603 100644 --- a/services/core/auth/src/nmp/core/auth/app/embedded_pdp/__init__.py +++ b/services/core/auth/src/nmp/core/auth/app/embedded_pdp/__init__.py @@ -19,6 +19,7 @@ set_policy_data, validate_entrypoint, ) +from .policy_wasm import ensure_embedded_policy_wasm, policy_wasm_needs_build __all__ = [ # Engine @@ -30,6 +31,8 @@ "reload_policy", "set_policy_data", "validate_entrypoint", + "ensure_embedded_policy_wasm", + "policy_wasm_needs_build", "load_policy_data", "apply_embedded_policy_document", ] 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 0d671814bf..8a74897f25 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 @@ -6,9 +6,9 @@ import json import logging import threading -from pathlib import Path from typing import Any, Dict, List, Optional, cast +from nmp.core.auth.app.embedded_pdp.policy_wasm import ensure_embedded_policy_wasm from wasmtime import Config, Engine, Func, FuncType, Instance, Limits, Memory, MemoryType, Module, Store, ValType logger = logging.getLogger(__name__) @@ -134,9 +134,7 @@ def get_policy() -> OPAPolicy: if _policy is None: with _policy_lock: if _policy is None: - path = Path(__file__).parent.parent.parent / "assets" / "policy.wasm" - if not path.exists(): - raise PolicyEngineError(f"policy.wasm not found at {path}. Run 'make build-policy'.") + path = ensure_embedded_policy_wasm() from nmp.common.config import get_service_config from nmp.core.auth.config import AuthServiceConfig diff --git a/services/core/auth/src/nmp/core/auth/app/embedded_pdp/policy_wasm.py b/services/core/auth/src/nmp/core/auth/app/embedded_pdp/policy_wasm.py new file mode 100644 index 0000000000..e94ae11ac2 --- /dev/null +++ b/services/core/auth/src/nmp/core/auth/app/embedded_pdp/policy_wasm.py @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Helpers for locating and building the embedded OPA WASM policy asset.""" + +import logging +import os +import subprocess +from collections.abc import Mapping +from pathlib import Path + +logger = logging.getLogger(__name__) + +AUTH_PACKAGE_DIR = Path(__file__).resolve().parents[2] +DEFAULT_POLICY_WASM_PATH = AUTH_PACKAGE_DIR / "assets" / "policy.wasm" +DEFAULT_POLICY_DIR = AUTH_PACKAGE_DIR / "app" / "policies" +DEFAULT_BUILD_TIMEOUT_SECONDS = 120 +OPA_VERSION = os.environ.get("OPA_VERSION", "v1.8.0") +OPA_VERSION_NO_V = OPA_VERSION.removeprefix("v") + + +class PolicyWasmError(RuntimeError): + """Raised when the embedded PDP WASM artifact cannot be prepared.""" + + +def discover_repo_root(start: Path | None = None) -> Path | None: + """Return the NeMo Platform source checkout root when running from source.""" + current = (start or Path(__file__).resolve()).resolve() + if current.is_file(): + current = current.parent + + for candidate in (current, *current.parents): + build_script = candidate / "script" / "build_policy_wasm.sh" + policy_dir = candidate / "services" / "core" / "auth" / "src" / "nmp" / "core" / "auth" / "app" / "policies" + if build_script.is_file() and policy_dir.is_dir(): + return candidate + return None + + +def _source_files(policy_dir: Path, repo_root: Path | None) -> list[Path]: + sources = list(policy_dir.glob("*.rego")) + if repo_root is not None: + build_script = repo_root / "script" / "build_policy_wasm.sh" + if build_script.is_file(): + sources.append(build_script) + return sources + + +def policy_wasm_needs_build( + *, + wasm_path: Path = DEFAULT_POLICY_WASM_PATH, + policy_dir: Path = DEFAULT_POLICY_DIR, + repo_root: Path | None = None, +) -> bool: + """Return True when policy.wasm is missing or older than source policy files.""" + if not wasm_path.exists(): + return True + + sources = _source_files(policy_dir, repo_root) + if not sources: + return False + + wasm_mtime = wasm_path.stat().st_mtime_ns + return any(source.stat().st_mtime_ns > wasm_mtime for source in sources) + + +def _opa_asset_name() -> str: + if os.uname().sysname.lower() == "darwin": + os_name = "darwin" + else: + os_name = "linux" + + machine = os.uname().machine.lower() + if machine in {"x86_64", "amd64"}: + arch = "amd64" + elif machine in {"aarch64", "arm64"}: + arch = "arm64" + else: + arch = machine + return f"opa_{os_name}_{arch}_static" + + +def _offline_build_hint(repo_root: Path, wasm_path: Path) -> str: + asset = _opa_asset_name() + cache_path = repo_root / ".cache" / "opa" / OPA_VERSION / asset + return ( + "\n\nOffline remediation options:\n" + f" 1. Provide a local OPA {OPA_VERSION} binary and rerun startup:\n" + f" OPA_BIN=/path/to/{asset} uv run nemo services run --host 127.0.0.1 --port 8080\n" + "\n" + " 2. Seed the script cache and rerun startup:\n" + f" mkdir -p {cache_path.parent}\n" + f" cp /path/to/{asset} {cache_path}\n" + f" chmod +x {cache_path}\n" + "\n" + " 3. If you only need to test startup with an already-built artifact, copy a current policy.wasm:\n" + f" cp /path/to/policy.wasm {wasm_path}\n" + f" touch {wasm_path}\n" + "\n" + f'The OPA binary must report "Version: {OPA_VERSION_NO_V}" from `/path/to/{asset} version`.' + ) + + +def _build_policy_wasm( + *, + repo_root: Path, + wasm_path: Path, + timeout_seconds: int, + env_overrides: Mapping[str, str] | None, +) -> None: + build_script = repo_root / "script" / "build_policy_wasm.sh" + if not build_script.is_file(): + raise PolicyWasmError( + f"Embedded auth PDP policy.wasm is missing or stale at {wasm_path}, " + f"but the build script was not found at {build_script}." + _offline_build_hint(repo_root, wasm_path) + ) + + build_env = os.environ.copy() + build_env.update( + { + "REPO_ROOT": str(repo_root), + "OUTPUT_DIR": str(wasm_path.parent), + } + ) + if env_overrides: + build_env.update(env_overrides) + + logger.info( + "Building embedded auth PDP policy.wasm", extra={"repo_root": str(repo_root), "wasm_path": str(wasm_path)} + ) + result = subprocess.run( + [str(build_script)], + cwd=repo_root, + env=build_env, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + if result.returncode != 0: + output = "\n".join(part for part in (result.stdout.strip(), result.stderr.strip()) if part) or "(no output)" + hint = "" if "Unable to prepare OPA" in output else _offline_build_hint(repo_root, wasm_path) + raise PolicyWasmError( + "Failed to build embedded auth PDP policy.wasm.\n\n" + "Command:\n" + " script/build_policy_wasm.sh\n\n" + f"Exit code: {result.returncode}\n\n" + "Build output:\n" + f"{output}" + hint + ) + + if not wasm_path.exists(): + raise PolicyWasmError( + f"script/build_policy_wasm.sh completed successfully but did not create policy.wasm at {wasm_path}." + ) + + +def ensure_embedded_policy_wasm( + *, + auto_build: bool = True, + wasm_path: Path = DEFAULT_POLICY_WASM_PATH, + policy_dir: Path = DEFAULT_POLICY_DIR, + repo_root: Path | None = None, + discover_source_checkout: bool = True, + build_timeout_seconds: int = DEFAULT_BUILD_TIMEOUT_SECONDS, + env_overrides: Mapping[str, str] | None = None, +) -> Path: + """Ensure the embedded PDP WASM artifact exists and is fresh enough to load.""" + resolved_repo_root = repo_root + if resolved_repo_root is None and discover_source_checkout: + resolved_repo_root = discover_repo_root() + + if not policy_wasm_needs_build(wasm_path=wasm_path, policy_dir=policy_dir, repo_root=resolved_repo_root): + return wasm_path + + if not auto_build: + raise PolicyWasmError( + f"Embedded auth PDP policy.wasm is missing or stale at {wasm_path}. " + "Run `make build-policy` from the NeMo Platform repo root, or set " + "`auth.embedded_pdp_auto_build_wasm=true` for local source checkouts." + ) + + if resolved_repo_root is None: + raise PolicyWasmError( + f"Embedded auth PDP policy.wasm is missing or stale at {wasm_path}, " + "and this does not look like a NeMo Platform source checkout. Rebuild the package/image " + "with the policy WASM artifact included." + ) + + _build_policy_wasm( + repo_root=resolved_repo_root, + wasm_path=wasm_path, + timeout_seconds=build_timeout_seconds, + env_overrides=env_overrides, + ) + return wasm_path diff --git a/services/core/auth/src/nmp/core/auth/service.py b/services/core/auth/src/nmp/core/auth/service.py index 17d54f164b..1af59512cc 100644 --- a/services/core/auth/src/nmp/core/auth/service.py +++ b/services/core/auth/src/nmp/core/auth/service.py @@ -13,6 +13,7 @@ from nmp.core.auth.api.v2.discovery import endpoints as discovery from nmp.core.auth.api.v2.iam import endpoints as iam from nmp.core.auth.app.embedded_pdp.data import apply_embedded_policy_document +from nmp.core.auth.app.embedded_pdp.policy_wasm import ensure_embedded_policy_wasm from nmp.core.auth.config import AuthServiceConfig logger = logging.getLogger(__name__) @@ -52,6 +53,8 @@ async def on_startup(self) -> None: if not config.enabled: logger.info("Auth disabled - skipping embedded policy engine initialization") return + if config.policy_decision_point_provider == "embedded": + ensure_embedded_policy_wasm(auto_build=config.embedded_pdp_auto_build_wasm) async def _start_refresh_loop(self) -> asyncio.Task: """Start background task to periodically refresh policy data.""" diff --git a/services/core/auth/tests/test_config.py b/services/core/auth/tests/test_config.py index be124b2938..ca75a7e75c 100644 --- a/services/core/auth/tests/test_config.py +++ b/services/core/auth/tests/test_config.py @@ -17,6 +17,7 @@ def test_auth_config_defaults(): # Auth service specific defaults assert cfg.policy_decision_point_provider == "embedded" assert cfg.policy_decision_point_request_timeout_seconds == 5 + assert cfg.embedded_pdp_auto_build_wasm is True assert cfg.bundle_cache_seconds == 5 assert cfg.admin_email is None assert cfg.default_workspace == "default" diff --git a/services/core/auth/tests/test_policy_wasm.py b/services/core/auth/tests/test_policy_wasm.py new file mode 100644 index 0000000000..0d09ca71c5 --- /dev/null +++ b/services/core/auth/tests/test_policy_wasm.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import subprocess +from pathlib import Path + +import pytest +from nmp.core.auth.app.embedded_pdp.policy_wasm import ( + PolicyWasmError, + ensure_embedded_policy_wasm, + policy_wasm_needs_build, +) + + +def test_policy_wasm_needs_build_when_missing(tmp_path: Path): + policy_dir = tmp_path / "policies" + policy_dir.mkdir() + + assert policy_wasm_needs_build(wasm_path=tmp_path / "policy.wasm", policy_dir=policy_dir) + + +def test_policy_wasm_needs_build_when_policy_source_is_newer(tmp_path: Path): + policy_dir = tmp_path / "policies" + policy_dir.mkdir() + source = policy_dir / "authz.rego" + wasm = tmp_path / "policy.wasm" + source.write_text("package authz\n") + wasm.write_bytes(b"wasm") + os.utime(wasm, (1, 1)) + os.utime(source, (2, 2)) + + assert policy_wasm_needs_build(wasm_path=wasm, policy_dir=policy_dir) + + +def test_policy_wasm_does_not_need_build_when_artifact_is_current(tmp_path: Path): + policy_dir = tmp_path / "policies" + policy_dir.mkdir() + source = policy_dir / "authz.rego" + wasm = tmp_path / "policy.wasm" + source.write_text("package authz\n") + wasm.write_bytes(b"wasm") + os.utime(source, (1, 1)) + os.utime(wasm, (2, 2)) + + assert not policy_wasm_needs_build(wasm_path=wasm, policy_dir=policy_dir) + + +def test_ensure_builds_missing_wasm_from_source_checkout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + repo_root = tmp_path / "repo" + build_script = repo_root / "script" / "build_policy_wasm.sh" + policy_dir = repo_root / "services" / "core" / "auth" / "src" / "nmp" / "core" / "auth" / "app" / "policies" + wasm = tmp_path / "out" / "policy.wasm" + build_script.parent.mkdir(parents=True) + policy_dir.mkdir(parents=True) + build_script.write_text("#!/usr/bin/env sh\n") + (policy_dir / "authz.rego").write_text("package authz\n") + + def fake_run(*args, **kwargs): + assert args[0] == [str(build_script)] + assert kwargs["cwd"] == repo_root + assert kwargs["env"]["REPO_ROOT"] == str(repo_root) + assert kwargs["env"]["OUTPUT_DIR"] == str(wasm.parent) + wasm.parent.mkdir(parents=True) + wasm.write_bytes(b"wasm") + return subprocess.CompletedProcess(args[0], 0, stdout="built", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert ( + ensure_embedded_policy_wasm( + wasm_path=wasm, + policy_dir=policy_dir, + repo_root=repo_root, + discover_source_checkout=False, + ) + == wasm + ) + + +def test_ensure_raises_when_auto_build_disabled(tmp_path: Path): + policy_dir = tmp_path / "policies" + policy_dir.mkdir() + + with pytest.raises(PolicyWasmError, match="make build-policy"): + ensure_embedded_policy_wasm( + auto_build=False, + wasm_path=tmp_path / "policy.wasm", + policy_dir=policy_dir, + discover_source_checkout=False, + ) + + +def test_ensure_raises_when_missing_outside_source_checkout(tmp_path: Path): + policy_dir = tmp_path / "policies" + policy_dir.mkdir() + + with pytest.raises(PolicyWasmError, match="source checkout"): + ensure_embedded_policy_wasm( + wasm_path=tmp_path / "policy.wasm", + policy_dir=policy_dir, + discover_source_checkout=False, + ) + + +def test_ensure_reports_build_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + repo_root = tmp_path / "repo" + build_script = repo_root / "script" / "build_policy_wasm.sh" + policy_dir = repo_root / "services" / "core" / "auth" / "src" / "nmp" / "core" / "auth" / "app" / "policies" + build_script.parent.mkdir(parents=True) + policy_dir.mkdir(parents=True) + build_script.write_text("#!/usr/bin/env sh\n") + (policy_dir / "authz.rego").write_text("package authz\n") + + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 2, stdout="out", stderr="err") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(PolicyWasmError, match="OPA_BIN"): + ensure_embedded_policy_wasm( + wasm_path=tmp_path / "out" / "policy.wasm", + policy_dir=policy_dir, + repo_root=repo_root, + discover_source_checkout=False, + ) diff --git a/services/core/auth/tests/test_service_policy_wasm.py b/services/core/auth/tests/test_service_policy_wasm.py new file mode 100644 index 0000000000..c134d62d68 --- /dev/null +++ b/services/core/auth/tests/test_service_policy_wasm.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from nmp.common.config import Configuration +from nmp.core.auth.config import AuthServiceConfig +from nmp.core.auth.service import AuthService + + +@pytest.fixture(autouse=True) +def clear_config_overrides(): + yield + Configuration.clear_override(AuthServiceConfig) + + +@pytest.mark.asyncio +async def test_on_startup_preflights_embedded_policy_wasm(monkeypatch: pytest.MonkeyPatch): + calls: list[bool] = [] + Configuration.set_override( + AuthServiceConfig( + enabled=True, + policy_decision_point_provider="embedded", + embedded_pdp_auto_build_wasm=False, + ) + ) + monkeypatch.setattr( + "nmp.core.auth.service.ensure_embedded_policy_wasm", lambda *, auto_build: calls.append(auto_build) + ) + + await AuthService().on_startup() + + assert calls == [False] + + +@pytest.mark.parametrize( + "config", + [ + AuthServiceConfig(enabled=False, policy_decision_point_provider="embedded"), + AuthServiceConfig(enabled=True, policy_decision_point_provider="opa"), + ], +) +@pytest.mark.asyncio +async def test_on_startup_skips_policy_wasm_preflight_when_not_needed( + config: AuthServiceConfig, + monkeypatch: pytest.MonkeyPatch, +): + calls: list[bool] = [] + Configuration.set_override(config) + monkeypatch.setattr( + "nmp.core.auth.service.ensure_embedded_policy_wasm", lambda *, auto_build: calls.append(auto_build) + ) + + await AuthService().on_startup() + + assert calls == []