diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index fdc902287..dc2570992 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.30" +version = "0.5.31" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/governance/__init__.py b/packages/uipath-core/src/uipath/core/governance/__init__.py index 4bf855b82..ddcb43118 100644 --- a/packages/uipath-core/src/uipath/core/governance/__init__.py +++ b/packages/uipath-core/src/uipath/core/governance/__init__.py @@ -9,7 +9,9 @@ from .config import ( GOVERNANCE_FEATURE_FLAG, + REGO_FEATURE_FLAG, is_governance_enabled, + is_rego_enabled, ) from .exceptions import ( GovernanceBlockException, @@ -19,10 +21,12 @@ ) from .models import Action, AuditRecord, EnforcementMode, LifecycleHook, RuleEvaluation from .providers import ( + AllPoliciesResponse, FiredRule, GovernanceCompensationProvider, GovernancePolicyProvider, GovernRequest, + HookBundle, PolicyContext, PolicyResponse, ) @@ -36,17 +40,21 @@ "RuleEvaluation", # Config "GOVERNANCE_FEATURE_FLAG", + "REGO_FEATURE_FLAG", "is_governance_enabled", + "is_rego_enabled", # Exceptions "GovernanceBlockException", "GovernanceConfigError", "GovernanceViolation", "Severity", # Provider protocols + wire models + "AllPoliciesResponse", "FiredRule", "GovernanceCompensationProvider", "GovernancePolicyProvider", "GovernRequest", + "HookBundle", "PolicyContext", "PolicyResponse", ] diff --git a/packages/uipath-core/src/uipath/core/governance/config.py b/packages/uipath-core/src/uipath/core/governance/config.py index cbcbd577a..0b3a96b62 100644 --- a/packages/uipath-core/src/uipath/core/governance/config.py +++ b/packages/uipath-core/src/uipath/core/governance/config.py @@ -18,6 +18,10 @@ # same toggle. GOVERNANCE_FEATURE_FLAG = "EnablePythonGovernanceChecker" +# Feature flag name controlling whether the Rego/WASM evaluator runs. +# Independent of the native evaluator flag — both can be enabled simultaneously. +REGO_FEATURE_FLAG = "EnablePythonGovernanceRegoEvaluator" + def is_governance_enabled() -> bool: """Return whether the ``EnablePythonGovernanceChecker`` flag is enabled. @@ -35,3 +39,21 @@ def is_governance_enabled() -> bool: 2. Default ``False`` (governance disabled). """ return FeatureFlags.is_flag_enabled(GOVERNANCE_FEATURE_FLAG, default=False) + + +def is_rego_enabled() -> bool: + """Return whether the ``EnablePythonGovernanceRegoEvaluator`` flag is enabled. + + Rego evaluation is **off by default** — the flag must be explicitly + set to ``true`` (programmatically via the ``FeatureFlags`` registry, + or via the ``UIPATH_FEATURE_EnablePythonGovernanceRegoEvaluator`` env + var) for this function to return ``True``. + + Resolution order: + + 1. :meth:`uipath.core.feature_flags.FeatureFlagsManager.is_flag_enabled` - + the in-process programmatic registry (typically populated from + gitops) and its own ``UIPATH_FEATURE_`` env-var fallback. + 2. Default ``False`` (Rego evaluation disabled). + """ + return FeatureFlags.is_flag_enabled(REGO_FEATURE_FLAG, default=False) diff --git a/packages/uipath-core/src/uipath/core/governance/providers.py b/packages/uipath-core/src/uipath/core/governance/providers.py index 5435ad389..9d1c8ee1a 100644 --- a/packages/uipath-core/src/uipath/core/governance/providers.py +++ b/packages/uipath-core/src/uipath/core/governance/providers.py @@ -78,6 +78,54 @@ def _coerce_mode(cls, value: object) -> EnforcementMode | None: return None +class HookBundle(BaseModel): + """Metadata for one hook's WASM policy bundle. + + Returned as an element of :class:`AllPoliciesResponse` from the + ``/all-policies/{tenant_id}`` endpoint. + + Attributes: + hook_type: Lifecycle hook identifier (e.g. ``"before_agent"``). + bundle_url: Pre-signed URL for the WASM ``.tar.gz`` bundle. + No platform auth required — the URL carries its own credentials. + etag: Server-assigned ETag for the bundle. Used by the Rego loader + to skip unchanged bundles on background refresh. ``None`` when + the server does not provide one. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + hook_type: str = Field(alias="hookType") + bundle_url: str = Field(alias="bundleUrl") + etag: str | None = Field(default=None) + + +class AllPoliciesResponse(BaseModel): + """Parsed response from the ``/all-policies/{tenant_id}`` endpoint. + + Wire envelope:: + + { + "hookBundles": [ + { + "hookType": "before_agent", + "bundleUrl": "https://url.example.com/...", + "etag": "abc123" + } + ] + } + + Attributes: + hook_bundles: One entry per lifecycle hook that has a compiled + WASM bundle. Empty when no policies are configured for the + tenant. + """ + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + hook_bundles: list[HookBundle] = Field(default_factory=list, alias="hookBundles") + + class FiredRule(BaseModel): """Per-rule metadata carried in the ``/runtime/govern`` payload. diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 7fcf797e7..40955931c 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.6" +version = "0.2.7" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/governance/__init__.py b/packages/uipath-platform/src/uipath/platform/governance/__init__.py index 1d9bdf7ee..89305c2b0 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/governance/__init__.py @@ -8,7 +8,7 @@ from ._governance_provider import UiPathPlatformGovernanceProvider from ._governance_service import GovernanceService from .compensate import FiredRule, GovernRequest -from .policy import PolicyContext, PolicyResponse +from .policy import AllPoliciesResponse, HookBundle, PolicyContext, PolicyResponse # ``_live_track_event_dispatcher.LiveTrackEventDispatcher`` is intentionally # **not** re-exported. It is host-wiring glue (the runtime sink's @@ -20,9 +20,11 @@ # ) __all__ = [ + "AllPoliciesResponse", "FiredRule", "GovernRequest", "GovernanceService", + "HookBundle", "PolicyContext", "PolicyResponse", "UiPathPlatformGovernanceProvider", diff --git a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py index 3546517c7..ad65f5a2f 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py +++ b/packages/uipath-platform/src/uipath/platform/governance/_governance_service.py @@ -7,6 +7,8 @@ - ``POST /{org}/agenticgovernance_/api/v1/runtime/govern`` — compensating governance call fired when a ``guardrail_fallback`` rule matches (see :meth:`GovernanceService.compensate`). +- ``GET /{org}/agenticgovernance_/api/v1/all-policies/{tenant_id}`` — fetch + WASM bundle metadata for all hooks (see :meth:`GovernanceService.retrieve_all_policies`). A third backend endpoint — ``POST /{org}/agenticgovernance_/api/v1/runtime/log`` — emits custom @@ -23,6 +25,7 @@ from uipath.core import traced from uipath.core.governance import ( + AllPoliciesResponse, FiredRule, GovernRequest, PolicyContext, @@ -44,6 +47,7 @@ POLICY_API_PATH = "api/v1/runtime/policy" GOVERN_API_PATH = "api/v1/runtime/govern" LOG_API_PATH = "api/v1/runtime/log" +ALL_POLICIES_API_PATH = "api/v1/all-policies" AGENT_TYPE_PARAM = "agentType" # Caller-set correlation id that becomes the App Insights ``operation_Id`` @@ -144,6 +148,104 @@ async def get_policy_async(self, context: PolicyContext) -> PolicyResponse: is_conversational=context.is_conversational ) + # ── Rego WASM bundle fetch ──────────────────────────────────────── + + @traced(name="governance_retrieve_all_policies", run_type="uipath") + def retrieve_all_policies(self) -> AllPoliciesResponse: + """Fetch WASM bundle metadata for all hooks for the active tenant. + + Calls ``GET /{org}/agenticgovernance_/api/v1/all-policies/{tenant_id}`` + and returns the list of :class:`HookBundle` objects — one per + lifecycle hook that has a compiled WASM policy bundle. Download + each bundle's bytes separately with :meth:`download_bundle`. + + Returns: + AllPoliciesResponse: List of hook bundles with pre-signed + URLs and ETags for cache-conditional re-fetches. The list + is empty when no Rego policies are configured for the + tenant. + + Raises: + ValueError: If ``UiPathConfig.organization_id`` or + ``UiPathConfig.tenant_id`` is not set. + EnrichedException: If the backend returns a non-2xx response. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + resp = client.governance.retrieve_all_policies() + for bundle in resp.hook_bundles: + data = client.governance.download_bundle(bundle.bundle_url) + ``` + """ + url, headers = self._build_org_scoped_request( + f"{ALL_POLICIES_API_PATH}/{UiPathConfig.tenant_id}" + ) + response = self.request("GET", url=url, headers=headers) + return AllPoliciesResponse.model_validate(response.json()) + + @traced(name="governance_retrieve_all_policies", run_type="uipath") + async def retrieve_all_policies_async(self) -> AllPoliciesResponse: + """Asynchronously fetch WASM bundle metadata for all hooks. + + See :meth:`retrieve_all_policies` for parameter and return semantics. + """ + url, headers = self._build_org_scoped_request( + f"{ALL_POLICIES_API_PATH}/{UiPathConfig.tenant_id}" + ) + response = await self.request_async("GET", url=url, headers=headers) + return AllPoliciesResponse.model_validate(response.json()) + + def download_bundle(self, bundle_url: str) -> bytes: + """Download a WASM bundle from a pre-signed URL. + + The URL returned by :meth:`retrieve_all_policies` is pre-signed + and carries its own credentials — no platform Bearer token is + sent. Callers should cache the result on disk; use the + :attr:`~HookBundle.etag` from :class:`AllPoliciesResponse` to + skip unchanged bundles on subsequent fetches. + + Args: + bundle_url: Pre-signed URL for the WASM ``.tar.gz`` bundle, + as returned in :attr:`HookBundle.bundle_url`. + + Returns: + Raw bytes of the ``.tar.gz`` bundle file. + + Raises: + httpx.HTTPStatusError: If the returns a non-2xx response. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + resp = client.governance.retrieve_all_policies() + for bundle in resp.hook_bundles: + data = client.governance.download_bundle(bundle.bundle_url) + # write data to disk ... + ``` + """ + import httpx + + response = httpx.get(bundle_url, follow_redirects=True) + response.raise_for_status() + return response.content + + async def download_bundle_async(self, bundle_url: str) -> bytes: + """Asynchronously download a WASM bundle from a pre-signed URL. + + See :meth:`download_bundle` for parameter and return semantics. + """ + import httpx + + async with httpx.AsyncClient() as client: + response = await client.get(bundle_url, follow_redirects=True) + response.raise_for_status() + return response.content + # ── Compensating governance call ───────────────────────────────── def compensate( diff --git a/packages/uipath-platform/src/uipath/platform/governance/policy.py b/packages/uipath-platform/src/uipath/platform/governance/policy.py index 27de1c9e7..77e9ab653 100644 --- a/packages/uipath-platform/src/uipath/platform/governance/policy.py +++ b/packages/uipath-platform/src/uipath/platform/governance/policy.py @@ -5,6 +5,11 @@ keeps the existing ``uipath.platform.governance`` import paths working. """ -from uipath.core.governance import PolicyContext, PolicyResponse +from uipath.core.governance import ( + AllPoliciesResponse, + HookBundle, + PolicyContext, + PolicyResponse, +) -__all__ = ["PolicyContext", "PolicyResponse"] +__all__ = ["AllPoliciesResponse", "HookBundle", "PolicyContext", "PolicyResponse"] diff --git a/packages/uipath-platform/tests/services/test_governance_service.py b/packages/uipath-platform/tests/services/test_governance_service.py index eb4941faf..d8966e56c 100644 --- a/packages/uipath-platform/tests/services/test_governance_service.py +++ b/packages/uipath-platform/tests/services/test_governance_service.py @@ -13,8 +13,10 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.common import resolve_trace_id from uipath.platform.governance import ( + AllPoliciesResponse, FiredRule, GovernanceService, + HookBundle, PolicyResponse, ) @@ -932,3 +934,286 @@ def test_falls_through_when_uipath_trace_id_is_malformed( # No OTel context active → falls through to caller-supplied fallback. assert resolve_trace_id(fallback="recovered") == "recovered" + + +_ALL_POLICIES_RESPONSE = { + "hookBundles": [ + { + "hookType": "before_agent", + "bundleUrl": "https://url.example.com/before_agent.tar.gz", + "etag": "etag-abc123", + }, + { + "hookType": "after_agent", + "bundleUrl": "https://url.example.com/after_agent.tar.gz", + "etag": None, + }, + ] +} + + +class TestRetrieveAllPolicies: + """Test retrieve_all_policies (sync) and retrieve_all_policies_async.""" + + def test_returns_parsed_response( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=200, + json=_ALL_POLICIES_RESPONSE, + ) + + result = service.retrieve_all_policies() + + assert isinstance(result, AllPoliciesResponse) + assert len(result.hook_bundles) == 2 + assert isinstance(result.hook_bundles[0], HookBundle) + assert result.hook_bundles[0].hook_type == "before_agent" + assert result.hook_bundles[0].bundle_url == ( + "https://url.example.com/before_agent.tar.gz" + ) + assert result.hook_bundles[0].etag == "etag-abc123" + assert result.hook_bundles[1].hook_type == "after_agent" + assert result.hook_bundles[1].etag is None + + def test_returns_empty_list_when_no_bundles( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=200, + json={"hookBundles": []}, + ) + + result = service.retrieve_all_policies() + + assert result.hook_bundles == [] + + def test_sends_tenant_header_and_bearer_token( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + secret: str, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json=_ALL_POLICIES_RESPONSE) + + httpx_mock.add_callback( + capture, + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + ) + + service.retrieve_all_policies() + + request = captured["request"] + assert request.method == "GET" + assert request.headers["x-uipath-internal-tenantid"] == TENANT_ID + assert request.headers["authorization"] == f"Bearer {secret}" + + def test_raises_when_organization_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + monkeypatch.setenv("UIPATH_TENANT_ID", TENANT_ID) + service = GovernanceService(config=config, execution_context=execution_context) + + with pytest.raises(ValueError, match="UIPATH_ORGANIZATION_ID"): + service.retrieve_all_policies() + + def test_raises_when_tenant_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + service = GovernanceService(config=config, execution_context=execution_context) + + with pytest.raises(ValueError, match="UIPATH_TENANT_ID"): + service.retrieve_all_policies() + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + from uipath.platform.errors import EnrichedException + + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=500, + text="server error", + ) + + with pytest.raises(EnrichedException): + service.retrieve_all_policies() + + def test_redirects_to_service_url_override( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv( + "UIPATH_SERVICE_URL_AGENTICGOVERNANCE", "http://localhost:8123" + ) + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json=_ALL_POLICIES_RESPONSE) + + httpx_mock.add_callback( + capture, + url=f"http://localhost:8123/api/v1/all-policies/{TENANT_ID}", + ) + + service.retrieve_all_policies() + + request = captured["request"] + assert request.headers["X-UiPath-Internal-AccountId"] == ORG_ID + assert ORG_ID not in str(request.url) + + async def test_async_returns_parsed_response( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + base_url: str, + ) -> None: + httpx_mock.add_response( + url=( + f"{base_url}/{ORG_ID}/agenticgovernance_" + f"/api/v1/all-policies/{TENANT_ID}" + ), + status_code=200, + json=_ALL_POLICIES_RESPONSE, + ) + + result = await service.retrieve_all_policies_async() + + assert isinstance(result, AllPoliciesResponse) + assert len(result.hook_bundles) == 2 + assert result.hook_bundles[0].hook_type == "before_agent" + + async def test_async_raises_when_tenant_id_missing( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", ORG_ID) + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + service = GovernanceService(config=config, execution_context=execution_context) + + with pytest.raises(ValueError, match="UIPATH_TENANT_ID"): + await service.retrieve_all_policies_async() + + +class TestDownloadBundle: + """Test download_bundle (sync) and download_bundle_async.""" + + BUNDLE_URL = "https://url.example.com/bundle.tar.gz" + BUNDLE_BYTES = b"fake-wasm-bundle-content" + + def test_returns_raw_bytes( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=200, + content=self.BUNDLE_BYTES, + ) + + result = service.download_bundle(self.BUNDLE_URL) + + assert result == self.BUNDLE_BYTES + + def test_does_not_send_bearer_token( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + captured: dict[str, httpx.Request] = {} + + def capture(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, content=self.BUNDLE_BYTES) + + httpx_mock.add_callback(capture, url=self.BUNDLE_URL) + + service.download_bundle(self.BUNDLE_URL) + + assert "authorization" not in captured["request"].headers + + def test_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=403, + text="forbidden", + ) + + with pytest.raises(httpx.HTTPStatusError): + service.download_bundle(self.BUNDLE_URL) + + async def test_async_returns_raw_bytes( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=200, + content=self.BUNDLE_BYTES, + ) + + result = await service.download_bundle_async(self.BUNDLE_URL) + + assert result == self.BUNDLE_BYTES + + async def test_async_raises_on_http_error( + self, + httpx_mock: HTTPXMock, + service: GovernanceService, + ) -> None: + httpx_mock.add_response( + url=self.BUNDLE_URL, + status_code=404, + text="not found", + ) + + with pytest.raises(httpx.HTTPStatusError): + await service.download_bundle_async(self.BUNDLE_URL) diff --git a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py index 675b42721..79830dedc 100644 --- a/packages/uipath/src/uipath/_cli/_governance_bootstrap.py +++ b/packages/uipath/src/uipath/_cli/_governance_bootstrap.py @@ -10,10 +10,10 @@ import atexit import logging from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from uipath.core.governance import EnforcementMode, PolicyContext -from uipath.core.governance.config import is_governance_enabled +from uipath.core.governance.config import is_governance_enabled, is_rego_enabled from uipath.platform import UiPath from uipath.platform.governance import UiPathPlatformGovernanceProvider from uipath.platform.governance._live_track_event_dispatcher import ( @@ -27,6 +27,7 @@ GuardrailCompensator, ) from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.rego import RegoEvaluator from uipath.runtime.governance.runtime import UiPathGovernedRuntime from ._governance import build_policy_index_from_yaml @@ -54,6 +55,7 @@ class GovernanceBootstrap: policy_index: PolicyIndex enforcement_mode: EnforcementMode dispose: Callable[[], None] + rego_evaluator: RegoEvaluator | None = field(default=None) def wrap_runtime( self, @@ -68,6 +70,7 @@ def wrap_runtime( policy_index=self.policy_index, enforcement_mode=self.enforcement_mode, evaluator=self.evaluator, + rego_evaluator=self.rego_evaluator, agent_name=agent_name, runtime_id=runtime_id, ) @@ -166,9 +169,26 @@ def dispose() -> None: ) return None + rego_evaluator: RegoEvaluator | None = None + if is_rego_enabled(): + try: + from uipath.runtime.governance.rego import build_rego_evaluator_async + + rego_evaluator = await build_rego_evaluator_async(sdk.governance) + if rego_evaluator is not None: + console.info( + f"Rego governance enabled " + f"(hooks={[h.value for h in rego_evaluator.loaded_hooks]})" + ) + except Exception as exc: + console.warning( + f"Rego governance setup failed - continuing without Rego evaluation: {exc}" + ) + return GovernanceBootstrap( evaluator=evaluator, policy_index=policy_index, enforcement_mode=response.mode, dispose=dispose, + rego_evaluator=rego_evaluator, )