-
Notifications
You must be signed in to change notification settings - Fork 3
feat(governance): enforcement-mode config, policy models, deps #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5b3582f
6da34f0
8e2c802
8140de4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| """Runtime-level governance enforcement-mode state. | ||
|
|
||
| The feature-flag gate (``is_governance_enabled``) lives in | ||
| :mod:`uipath.core.governance.config` because it is process-level and | ||
| must be resolvable by callers that do not depend on | ||
| ``uipath-runtime``. The enforcement mode is *per-policy* — owned by the | ||
| backend and delivered on each policy fetch via the ``/runtime/policy`` | ||
| endpoint — and therefore lives here in the runtime package alongside the | ||
| policy loader that applies it. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| # ``EnforcementMode`` is the shared governance value type; it's defined in | ||
| # uipath.core.governance (a lower abstraction level) and re-exported here so | ||
| # runtime callers keep a single import site. The per-process mode *state* | ||
| # below is runtime-owned and applied by the policy loader. | ||
| from uipath.core.governance import EnforcementMode as EnforcementMode | ||
|
|
||
|
|
||
| class _EnforcementModeState: | ||
| """Holds the active enforcement mode. | ||
|
|
||
| A single module-level instance backs the get/set/reset helpers, so the | ||
| mode is updated by mutating an attribute rather than rebinding a module | ||
| global. ``mode is None`` means "not yet set by the backend" — until | ||
| then (and if the backend omits a mode) governance defaults to AUDIT. | ||
| """ | ||
|
|
||
| def __init__(self) -> None: | ||
| self.mode: EnforcementMode | None = None | ||
|
|
||
|
|
||
| # The enforcement mode is owned by the backend: the policy loader applies | ||
| # the mode from the ``/runtime/policy`` response via | ||
| # :func:`set_enforcement_mode`. | ||
| _state = _EnforcementModeState() | ||
|
|
||
|
|
||
| def get_enforcement_mode() -> EnforcementMode: | ||
| """Return the current enforcement mode. | ||
|
|
||
| The canonical source is the backend ``/runtime/policy`` response, | ||
| applied by the policy loader via :func:`set_enforcement_mode`. Until | ||
| that fetch lands (or if the backend returns no mode), the default is | ||
| :attr:`EnforcementMode.AUDIT` — evaluate and log without blocking. | ||
| Defaulting to AUDIT avoids the chicken-and-egg where a DISABLED | ||
| default would short-circuit evaluation before the background policy | ||
| fetch could ever opt the tenant in. | ||
| """ | ||
| return _state.mode if _state.mode is not None else EnforcementMode.AUDIT | ||
|
|
||
|
|
||
| def set_enforcement_mode(mode: EnforcementMode) -> None: | ||
| """Set the enforcement mode programmatically. | ||
|
|
||
| The policy loader calls this with the backend-supplied mode on each | ||
| fetch so the evaluator picks up the platform-controlled value. | ||
| """ | ||
| _state.mode = mode | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| """Native policy model. | ||
|
|
||
| Rules, checks, conditions and pack indexes consumed by the native | ||
| governance evaluator. | ||
|
|
||
| These are the inputs of the native evaluator. The evaluator-agnostic | ||
| *output* types (``Action``, ``AuditRecord``, …) live in | ||
| :mod:`uipath.core.governance.models`. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from enum import Enum | ||
| from typing import Any | ||
|
|
||
| from uipath.core.governance.models import Action, LifecycleHook | ||
|
|
||
|
|
||
| class Severity(Enum): | ||
| """Rule severity levels.""" | ||
|
|
||
| LOW = "low" | ||
| MEDIUM = "medium" | ||
| HIGH = "high" | ||
| CRITICAL = "critical" | ||
|
|
||
|
|
||
| class Logic(str, Enum): | ||
| """How a check combines its conditions.""" | ||
|
|
||
| ALL = "all" # AND — every condition must hold. | ||
| ANY = "any" # OR — any matching condition is a hit. | ||
|
|
||
|
|
||
| @dataclass | ||
| class Condition: | ||
| """A single condition within a rule check.""" | ||
|
|
||
| operator: str | ||
| field: str | ||
| value: Any | ||
| negate: bool = False | ||
|
|
||
|
|
||
| @dataclass | ||
| class Check: | ||
| """A check within a rule - contains conditions and action.""" | ||
|
|
||
| conditions: list[Condition] | ||
| action: Action = Action.DENY | ||
| message: str = "" | ||
| logic: Logic = Logic.ALL | ||
|
|
||
|
|
||
| @dataclass | ||
| class Rule: | ||
| """A compliance rule with checks evaluated at a specific lifecycle hook.""" | ||
|
|
||
| rule_id: str | ||
| name: str | ||
| clause: str | ||
| hook: LifecycleHook | ||
| action: Action | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we also have an action per check. what happens if ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For Rule.action = AUDIT, Check.action = DENY: the matched check's action wins, so the rule resolves to DENY. Then enforcement mode decides: ENFORCE blocks it, AUDIT downgrades it to log-only, DISABLED skips.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. makes sense, thanks. |
||
| severity: Severity = Severity.HIGH | ||
| checks: list[Check] = field(default_factory=list) | ||
| enabled: bool = True | ||
| description: str = "" | ||
| pack_name: str = "" | ||
|
|
||
| # Approval configuration (for ESCALATE action) | ||
| approval_config: dict[str, Any] = field(default_factory=dict) | ||
|
|
||
|
|
||
| @dataclass | ||
| class CheckContext: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. instead of a dataclass with a large bag of fields we should use hook-specific contexts as pydantic objects and create a union type for them. this will provide out-of-the-box model validation so one can t write stuff like:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Splitting CheckContext into per hook Pydantic types plus a union touches multiple files: the models definition, every hook construction site in the evaluator, the places reading the context fields, and the context tests. Since it spans the stack, I'll track it in the parent PR and do it once these get merged. |
||
| """Context passed to rule evaluation.""" | ||
|
|
||
| hook: LifecycleHook | ||
| agent_name: str | ||
| runtime_id: str | ||
| trace_id: str | ||
|
|
||
| # Content fields (populated based on hook) | ||
| agent_input: str = "" | ||
| agent_output: str = "" | ||
| model_input: str = "" | ||
| model_output: str = "" | ||
| model_name: str = "" | ||
| tool_name: str = "" | ||
| tool_args: dict[str, Any] = field(default_factory=dict) | ||
| tool_result: str = "" | ||
| messages: list[dict[str, Any]] = field(default_factory=list) | ||
|
|
||
| # Session state | ||
| session_state: dict[str, Any] = field(default_factory=dict) | ||
| metadata: dict[str, Any] = field(default_factory=dict) | ||
|
|
||
| # Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted) | ||
| ring: int = 2 | ||
|
|
||
|
|
||
| @dataclass | ||
| class PolicyPack: | ||
| """A collection of rules for a compliance standard.""" | ||
|
|
||
| name: str | ||
| version: str | ||
| description: str | ||
| rules: list[Rule] | ||
| enabled: bool = True | ||
|
|
||
|
|
||
| @dataclass | ||
| class PolicyIndex: | ||
| """Index of all loaded policy packs and rules.""" | ||
|
|
||
| packs: dict[str, PolicyPack] = field(default_factory=dict) | ||
| _rules_by_id: dict[str, Rule] = field(default_factory=dict) | ||
| _rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict) | ||
|
|
||
| def add_pack(self, pack: PolicyPack) -> None: | ||
| """Add a policy pack to the index.""" | ||
| self.packs[pack.name] = pack | ||
| for rule in pack.rules: | ||
| rule.pack_name = pack.name | ||
| self._rules_by_id[rule.rule_id] = rule | ||
| if rule.hook not in self._rules_by_hook: | ||
| self._rules_by_hook[rule.hook] = [] | ||
| self._rules_by_hook[rule.hook].append(rule) | ||
|
|
||
| def get_rule(self, rule_id: str) -> Rule | None: | ||
| """Get a rule by ID.""" | ||
| return self._rules_by_id.get(rule_id) | ||
|
|
||
| def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]: | ||
| """Get all rules for a lifecycle hook.""" | ||
| return self._rules_by_hook.get(hook, []) | ||
|
|
||
| def get_rules_for_pack(self, pack_name: str) -> list[Rule]: | ||
| """Get all rules for a pack.""" | ||
| pack = self.packs.get(pack_name) | ||
| return pack.rules if pack else [] | ||
|
|
||
| @property | ||
| def pack_names(self) -> list[str]: | ||
| """Get all pack names.""" | ||
| return list(self.packs.keys()) | ||
|
|
||
| @property | ||
| def total_rules(self) -> int: | ||
| """Get total number of rules.""" | ||
| return len(self._rules_by_id) | ||
|
|
||
| @property | ||
| def all_rules(self) -> list[Rule]: | ||
| """Get all rules.""" | ||
| return list(self._rules_by_id.values()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| """Shared test-only helpers. | ||
|
|
||
| Keeps test concerns out of the production governance package: the | ||
| enforcement-mode reset used for per-test isolation lives here rather than | ||
| in :mod:`uipath.runtime.governance.config`. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from uipath.runtime.governance import config | ||
|
|
||
|
|
||
| def reset_enforcement_mode() -> None: | ||
| """Clear the process-wide enforcement mode so the AUDIT default re-applies. | ||
|
|
||
| Test isolation only — production code never resets the mode; the policy | ||
| loader sets it from the backend ``/runtime/policy`` response. | ||
| """ | ||
| config._state.mode = None |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| """Tests for the default enforcement-mode resolution. | ||
|
|
||
| The default is :attr:`EnforcementMode.AUDIT` so the wrapper attaches at | ||
| runtime construction and the background policy fetch can run. If the | ||
| backend later returns ``disabled``, ``set_enforcement_mode`` flips the | ||
| mode and ``evaluate()`` short-circuits per-call. | ||
|
|
||
| Resolution (per :func:`get_enforcement_mode`): | ||
| 1. The backend-supplied value set via ``set_enforcement_mode`` (the | ||
| ``/runtime/policy`` response, applied by the policy loader). | ||
| 2. Default ``AUDIT``. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from tests._helpers import reset_enforcement_mode | ||
| from uipath.runtime.governance.config import ( | ||
| EnforcementMode, | ||
| get_enforcement_mode, | ||
| set_enforcement_mode, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _isolate_mode(): | ||
| """Each test starts from a clean module-state slate.""" | ||
| reset_enforcement_mode() | ||
| yield | ||
| reset_enforcement_mode() | ||
|
|
||
|
|
||
| def test_default_mode_is_audit() -> None: | ||
| """No backend-supplied mode → AUDIT. | ||
|
|
||
| AUDIT is the default so the wrapper attaches and the background | ||
| policy fetch can run. The backend can flip the mode to DISABLED | ||
| on fetch when the tenant has no policies. | ||
| """ | ||
| assert get_enforcement_mode() is EnforcementMode.AUDIT | ||
|
|
||
|
|
||
| def test_backend_disabled_wins_over_default() -> None: | ||
| """The backend mode (via ``set_enforcement_mode``) overrides the default.""" | ||
| set_enforcement_mode(EnforcementMode.DISABLED) | ||
| assert get_enforcement_mode() is EnforcementMode.DISABLED | ||
|
|
||
|
|
||
| def test_backend_enforce_wins_over_default() -> None: | ||
| set_enforcement_mode(EnforcementMode.ENFORCE) | ||
| assert get_enforcement_mode() is EnforcementMode.ENFORCE | ||
|
|
||
|
|
||
| def test_reset_returns_to_default() -> None: | ||
| """``reset_enforcement_mode`` clears the mode so the default re-applies.""" | ||
| set_enforcement_mode(EnforcementMode.ENFORCE) | ||
| assert get_enforcement_mode() is EnforcementMode.ENFORCE | ||
| reset_enforcement_mode() | ||
| assert get_enforcement_mode() is EnforcementMode.AUDIT |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this still leaves enforcement mode as process-global state in this PR, just behind
_stateinstead of aglobalvariable. shouldn t the resolved mode live under the evaluator instance?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, I'll move the resolved mode onto the evaluator instance in a follow up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#130
created an issue for better tracking