diff --git a/.github/workflows/py-sdk-build-publish.yml b/.github/workflows/py-sdk-build-publish.yml index b36f8ef..3594464 100644 --- a/.github/workflows/py-sdk-build-publish.yml +++ b/.github/workflows/py-sdk-build-publish.yml @@ -119,6 +119,7 @@ jobs: publish_test: name: Publish to Test PyPI + if: github.ref != 'refs/heads/main' needs: [build_wheels, build_sdist] runs-on: ubuntu-latest #environment: pypi @@ -205,8 +206,4 @@ jobs: - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: - packages-dir: dist - - - uses: softprops/action-gh-release@v2 - with: - files: dist/* \ No newline at end of file + packages-dir: dist \ No newline at end of file diff --git a/.gitignore b/.gitignore index 362e5fc..6b02796 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,6 @@ __pycache__/ lib_actra_core.dylib.dSYM/ .pytest_cache/ venv_test -wheelhouse \ No newline at end of file +wheelhouse +____*/ +____* \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 6b78db9..7487092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.2" +version = "0.3.0" edition = "2021" license = "Apache-2.0" authors = ["Amit Saxena"] diff --git a/examples/python/core-sdk/basic_refund.py b/examples/python/core-sdk/basic_refund.py new file mode 100644 index 0000000..f550338 --- /dev/null +++ b/examples/python/core-sdk/basic_refund.py @@ -0,0 +1,124 @@ +""" +Basic Actra Example + +This example shows how Actra can enforce admission +control policies on normal Python functions. + +Actra evaluates a policy BEFORE the function executes +and blocks the call if the policy denies it. +""" + +from actra import Actra +from actra.runtime import ActraRuntime + + +# ------------------------------------------------------------ +# 1. Schema definition +# ------------------------------------------------------------ +# The schema defines the structure of data that policies +# are allowed to reference. +# +# Domains: +# - action : parameters passed to the function +# - actor : identity of the caller +# - snapshot : external system state +# +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +# ------------------------------------------------------------ +# 2. Policy definition +# ------------------------------------------------------------ +# This policy blocks refunds larger than 1000 +# +# Scope limits the rule to the "refund" action +# The rule inspects the action.amount field +# +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +""" + +# ------------------------------------------------------------ +# 3. Compile policy +# ------------------------------------------------------------ + +policy = Actra.from_strings(schema_yaml, policy_yaml) + +# ------------------------------------------------------------ +# 4. Create runtime +# ------------------------------------------------------------ + +runtime = ActraRuntime(policy) + +# ------------------------------------------------------------ +# 5. Register context resolvers +# ------------------------------------------------------------ +# Resolvers dynamically supply runtime context used by policies. +# +# actor_resolver : information about the caller +# snapshot_resolver: external system state +# + +runtime.set_actor_resolver(lambda ctx: {"role": "support"}) +runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + + +# ------------------------------------------------------------ +# 6. Protect a function with Actra +# ------------------------------------------------------------ +# The @runtime.admit decorator intercepts the function call +# and evaluates policies before execution. +# +# 1. Default mapping +# @runtime.admit() : all kwargs become action fields +# +# 2. Field filtering +# @runtime.admit(fields=["amount"]) +# +# 3. Custom action builder +# @runtime.admit(action_builder=my_builder) +# + +@runtime.admit() +def refund(amount: int): + print("Refund executed:", amount) + + +# ------------------------------------------------------------ +# 7. Execute calls +# ------------------------------------------------------------ + +print("\n--- Allowed call ---") +refund(amount=200) + + +print("\n--- Blocked call ---") +refund(amount=1500) \ No newline at end of file diff --git a/examples/python/core-sdk/custom_action_builder.py b/examples/python/core-sdk/custom_action_builder.py new file mode 100644 index 0000000..65106c8 --- /dev/null +++ b/examples/python/core-sdk/custom_action_builder.py @@ -0,0 +1,107 @@ +""" +Custom Action Builder Example + +This example demonstrates how to provide a custom function +to construct the Actra action object. + +Custom builders are useful when application inputs do not +map directly to policy fields. +""" + +from actra import Actra +from actra.runtime import ActraRuntime + + +# ------------------------------------------------------------ +# 1. Schema +# ------------------------------------------------------------ + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +# ------------------------------------------------------------ +# 2. Policy +# ------------------------------------------------------------ + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +""" + + +# ------------------------------------------------------------ +# 3. Compile policy +# ------------------------------------------------------------ + +policy = Actra.from_strings(schema_yaml, policy_yaml) +runtime = ActraRuntime(policy) + + +# ------------------------------------------------------------ +# 4. Register resolvers +# ------------------------------------------------------------ + +runtime.set_actor_resolver(lambda ctx: {"role": "support"}) +runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + + +# ------------------------------------------------------------ +# 5. Custom action builder +# ------------------------------------------------------------ + +def build_refund_action(action_type, args, kwargs, ctx): + """ + Convert application inputs into the policy action object. + """ + + return { + "type": action_type, + "amount": kwargs["amount"] + } + + +# ------------------------------------------------------------ +# 6. Protect function using custom builder +# ------------------------------------------------------------ + +@runtime.admit(action_builder=build_refund_action) +def refund(amount: int, currency: str): + print(f"Refund executed: {amount} {currency}") + + +# ------------------------------------------------------------ +# 7. Calls +# ------------------------------------------------------------ + +print("\nAllowed call") +refund(amount=200, currency="USD") + +print("\nBlocked call") +refund(amount=1500, currency="USD") \ No newline at end of file diff --git a/examples/python/core-sdk/custom_actor_snapshot_resolvers.py b/examples/python/core-sdk/custom_actor_snapshot_resolvers.py new file mode 100644 index 0000000..4202808 --- /dev/null +++ b/examples/python/core-sdk/custom_actor_snapshot_resolvers.py @@ -0,0 +1,88 @@ +""" +Custom Actor and Snapshot Resolvers Example + +Demonstrates how runtime context can be used to dynamically +resolve actor and system state. +""" + +from actra import Actra, ActraRuntime + + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund_for_support + scope: + action: refund + when: + all: + - subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + - subject: + domain: actor + field: role + operator: equals + value: + literal: "support" + effect: block +""" + + +policy = Actra.from_strings(schema_yaml, policy_yaml) +runtime = ActraRuntime(policy) + + +# Example request context +class RequestContext: + def __init__(self, role, fraud_flag): + self.role = role + self.fraud_flag = fraud_flag + + +# Actor resolver +runtime.set_actor_resolver( + lambda ctx: {"role": ctx.role} +) + +# Snapshot resolver +runtime.set_snapshot_resolver( + lambda ctx: {"fraud_flag": ctx.fraud_flag} +) + + +ctx = RequestContext(role="support", fraud_flag=False) + + +action = runtime.build_action( + action_type="refund", + args=(), + kwargs={"amount": 2000}, + ctx=ctx +) + +decision = runtime.evaluate(action, ctx) + +print(decision) \ No newline at end of file diff --git a/examples/python/core-sdk/fields_filtering.py b/examples/python/core-sdk/fields_filtering.py new file mode 100644 index 0000000..16513ea --- /dev/null +++ b/examples/python/core-sdk/fields_filtering.py @@ -0,0 +1,88 @@ +""" +Field Filtering Example + +This example demonstrates how to restrict which parameters +become part of the Actra action object. + +This is useful when functions contain additional arguments +that should not be exposed to the policy engine. +""" + +from actra import Actra +from actra.runtime import ActraRuntime + + +# ------------------------------------------------------------ +# 1. Schema +# ------------------------------------------------------------ + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +# ------------------------------------------------------------ +# 2. Policy +# ------------------------------------------------------------ + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +""" + + +# ------------------------------------------------------------ +# 3. Compile policy and create runtime +# ------------------------------------------------------------ + +policy = Actra.from_strings(schema_yaml, policy_yaml) +runtime = ActraRuntime(policy) + + +runtime.set_actor_resolver(lambda ctx: {"role": "support"}) +runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + + +# ------------------------------------------------------------ +# 4. Protect function with field filtering +# ------------------------------------------------------------ + +@runtime.admit(fields=["amount"]) +def refund(amount: int, currency: str): + print(f"Refund executed: {amount} {currency}") + + +# ------------------------------------------------------------ +# 5. Calls +# ------------------------------------------------------------ + +print("\nAllowed call") +refund(amount=200, currency="USD") + +print("\nBlocked call") +refund(amount=1500, currency="USD") \ No newline at end of file diff --git a/examples/python/core-sdk/manual_runtime_evaluation.py b/examples/python/core-sdk/manual_runtime_evaluation.py new file mode 100644 index 0000000..d0016a6 --- /dev/null +++ b/examples/python/core-sdk/manual_runtime_evaluation.py @@ -0,0 +1,120 @@ +""" +Manual Runtime Evaluation Example + +This example demonstrates how to use ActraRuntime directly +without decorators. + +This pattern is useful for integrations such as: +- API frameworks +- MCP servers +- background workers +- CLI tools + +The application constructs an action object and asks +the runtime to evaluate it. +""" + +from actra import Actra +from actra.runtime import ActraRuntime + +# ------------------------------------------------------------ +# 1. Schema +# ------------------------------------------------------------ + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + +# ------------------------------------------------------------ +# 2. Policy +# ------------------------------------------------------------ + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +""" + +# ------------------------------------------------------------ +# 3. Compile policy +# ------------------------------------------------------------ + +policy = Actra.from_strings(schema_yaml, policy_yaml) + +# ------------------------------------------------------------ +# 4. Create runtime +# ------------------------------------------------------------ + +runtime = ActraRuntime(policy) + +# ------------------------------------------------------------ +# 5. Context resolvers +# ------------------------------------------------------------ + +runtime.set_actor_resolver(lambda ctx: {"role": "support"}) +runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + +# ------------------------------------------------------------ +# 6. Build an action manually +# ------------------------------------------------------------ +# In many integrations the action will come from: +# - HTTP requests +# - tool calls +# - message queues +# + +action = runtime.build_action( + action_type="refund", + args=(), + kwargs={"amount": 200}, + ctx=None +) + +# ------------------------------------------------------------ +# 7. Evaluate the action +# ------------------------------------------------------------ + +result = runtime.evaluate(action) + +print("\nEvaluation result:") +print(result) + +# ------------------------------------------------------------ +# 8. Blocked example +# ------------------------------------------------------------ + +blocked_action = runtime.build_action( + action_type="refund", + args=(), + kwargs={"amount": 1500}, + ctx=None +) + +blocked_result = runtime.evaluate(blocked_action) + +print("\nBlocked evaluation result:") +print(blocked_result) \ No newline at end of file diff --git a/examples/python/core-sdk/multiple_runtimes.py b/examples/python/core-sdk/multiple_runtimes.py new file mode 100644 index 0000000..0ee718c --- /dev/null +++ b/examples/python/core-sdk/multiple_runtimes.py @@ -0,0 +1,170 @@ +""" +Multiple Runtimes Example + +This example demonstrates that multiple Actra runtimes +can coexist in the same application. + +Each runtime can enforce a different policy. +""" + +from actra import Actra +from actra.runtime import ActraRuntime + + +# ------------------------------------------------------------ +# 1. Schema +# ------------------------------------------------------------ + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +# ------------------------------------------------------------ +# 2. Policy A — support users +# ------------------------------------------------------------ + +support_policy_yaml = """ +version: 1 + +rules: + - id: support_limit + scope: + action: refund + when: + all: + - subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + - subject: + domain: actor + field: role + operator: equals + value: + literal: "support" + effect: block +""" + + +# ------------------------------------------------------------ +# 3. Policy B — admin users +# ------------------------------------------------------------ + +admin_policy_yaml = """ +version: 1 + +rules: + - id: admin_limit + scope: + action: refund + when: + all: + - subject: + domain: action + field: amount + operator: greater_than + value: + literal: 10000 + - subject: + domain: actor + field: role + operator: equals + value: + literal: "admin" + effect: block +""" + +# ------------------------------------------------------------ +# 4. Compile policies +# ------------------------------------------------------------ + +support_policy = Actra.from_strings(schema_yaml, support_policy_yaml) +admin_policy = Actra.from_strings(schema_yaml, admin_policy_yaml) + + +# ------------------------------------------------------------ +# 5. Create runtimes +# ------------------------------------------------------------ + +support_runtime = ActraRuntime(support_policy) +admin_runtime = ActraRuntime(admin_policy) + + +support_runtime.set_actor_resolver(lambda ctx: {"role": "support"}) +support_runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + +admin_runtime.set_actor_resolver(lambda ctx: {"role": "admin"}) +admin_runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + + +# ------------------------------------------------------------ +# 6. Protected functions +# ------------------------------------------------------------ + +# IMPORTANT: +# By default, the Actra decorator uses the Python function name +# as the action type when constructing the policy action. +# +# For example: +# def support_refund() : action.type = "support_refund" +# +# However, the policy in this example expects the action: +# +# scope: +# action: refund +# +# Therefore we override the action name using: +# +# action_type="refund" +# +# This maps both functions to the same policy action. +# + +@support_runtime.admit(action_type="refund") +def support_refund(amount: int): + print(f"Support refund executed: {amount}") + + +@admin_runtime.admit(action_type="refund") +def admin_refund(amount: int): + print(f"Admin refund executed: {amount}") + + +# ------------------------------------------------------------ +# 7. Calls +# ------------------------------------------------------------ + +print("\nSupport runtime") + +support_refund(amount=200) + +try: + support_refund(amount=5000) +except PermissionError as e: + print(e) + + +print("\nAdmin runtime") + +admin_refund(amount=5000) + +try: + admin_refund(amount=20000) +except PermissionError as e: + print(e) \ No newline at end of file diff --git a/examples/python/core-sdk/policy/policy.yaml b/examples/python/core-sdk/policy/policy.yaml new file mode 100644 index 0000000..cb9dd80 --- /dev/null +++ b/examples/python/core-sdk/policy/policy.yaml @@ -0,0 +1,14 @@ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block \ No newline at end of file diff --git a/examples/python/core-sdk/policy/schema.yaml b/examples/python/core-sdk/policy/schema.yaml new file mode 100644 index 0000000..821c20a --- /dev/null +++ b/examples/python/core-sdk/policy/schema.yaml @@ -0,0 +1,14 @@ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean \ No newline at end of file diff --git a/examples/python/core-sdk/policy_assertion.py b/examples/python/core-sdk/policy_assertion.py new file mode 100644 index 0000000..1e6f2dc --- /dev/null +++ b/examples/python/core-sdk/policy_assertion.py @@ -0,0 +1,18 @@ +""" +Policy Assertion Example + +Demonstrates how to validate policies in tests. +""" + +from actra import Actra + +#Make sure required folder exists +policy = Actra.from_directory("policy") + +policy.assert_effect({ + "action": {"type": "refund", "amount": 200}, + "actor": {"role": "support"}, + "snapshot": {"fraud_flag": False} +}, "allow") + +print("Policy test passed") \ No newline at end of file diff --git a/examples/python/core-sdk/policy_direct_evaluation.py b/examples/python/core-sdk/policy_direct_evaluation.py new file mode 100644 index 0000000..4fe3b31 --- /dev/null +++ b/examples/python/core-sdk/policy_direct_evaluation.py @@ -0,0 +1,51 @@ +""" +Policy Direct Evaluation Example + +Demonstrates how to evaluate policies without using the runtime. +""" + +from actra import Actra + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +""" + +policy = Actra.from_strings(schema_yaml, policy_yaml) + +decision = policy.evaluate({ + "action": {"type": "refund", "amount": 1500}, + "actor": {"role": "support"}, + "snapshot": {"fraud_flag": False} +}) + +print(decision) \ No newline at end of file diff --git a/examples/python/core-sdk/policy_testing.py b/examples/python/core-sdk/policy_testing.py new file mode 100644 index 0000000..7916bb8 --- /dev/null +++ b/examples/python/core-sdk/policy_testing.py @@ -0,0 +1,65 @@ +""" +Policy Testing Example + +Demonstrates how to verify policy behaviour using assertions. +""" + +from actra import Actra + + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + scope: + action: refund + when: + subject: + domain: action + field: amount + operator: greater_than + value: + literal: 1000 + effect: block +""" + + +policy = Actra.from_strings(schema_yaml, policy_yaml) + + +allow_context = { + "action": {"type": "refund", "amount": 200}, + "actor": {"role": "support"}, + "snapshot": {"fraud_flag": False}, +} + +block_context = { + "action": {"type": "refund", "amount": 2000}, + "actor": {"role": "support"}, + "snapshot": {"fraud_flag": False}, +} + + +policy.assert_effect(allow_context, "allow") +policy.assert_effect(block_context, "block") + +print("Policy tests passed.") \ No newline at end of file diff --git a/sdk/python/README.md b/sdk/python/README.md index 12c1fe4..a2b3438 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -1,11 +1,21 @@ # Actra Python SDK -Deterministic admission control for state-changing operations in automated and agentic systems. +Deterministic admission control and policy evaluation for state-changing +operations in automated and agentic systems. The **Actra Python SDK** provides a simple interface for loading policies and evaluating decisions using the Actra engine written in Rust. --- +## Runtime Admission Control + +The SDK also provides a runtime layer for protecting functions using Actra policies. + +If a policy blocks the operation, the function will not execute and a +PermissionError will be raised. + +--- + ## Installation Install from PyPI: @@ -22,19 +32,24 @@ The package includes a compiled Rust engine, so no Rust toolchain is required du ```python import actra +from actra import ActraRuntime policy = actra.load_policy_from_file( "schema.yaml", "policy.yaml" ) -decision = policy.evaluate({ - "action": {"type": "deploy"}, - "actor": {"role": "admin"}, - "snapshot": {} -}) +runtime = ActraRuntime(policy) + +runtime.set_actor_resolver(lambda ctx: {"role": "admin"}) +runtime.set_snapshot_resolver(lambda ctx: {}) + +@runtime.admit() +def deploy(): + print("Deployment executed") + +deploy() -print(decision) ``` --- @@ -126,29 +141,6 @@ actra.Actra.compiler_version() --- -## Example - -```python -import actra - -policy = actra.load_policy_from_file( - "schema.yaml", - "policy.yaml" -) - -request = { - "action": {"type": "deploy"}, - "actor": {"role": "developer"}, - "snapshot": {} -} - -decision = policy.evaluate(request) - -print("Decision:", decision) -``` - ---- - ## Design Goals The Python SDK focuses on: diff --git a/sdk/python/actra/__init__.py b/sdk/python/actra/__init__.py index 6b5baa3..2bbc29e 100644 --- a/sdk/python/actra/__init__.py +++ b/sdk/python/actra/__init__.py @@ -1,6 +1,9 @@ -from .policy import Actra, Policy from importlib.metadata import version, PackageNotFoundError +from .policy import Actra, Policy +from .runtime import ActraRuntime +from .types import Action, Actor, Snapshot, Decision, Context + try: __version__ = version("actra") except PackageNotFoundError: @@ -13,8 +16,19 @@ __all__ = [ "Actra", "Policy", + "ActraRuntime", + + # Runtime types + "Action", + "Actor", + "Snapshot", + "Decision", + "Context", + + # Helpers "load_policy_from_file", "load_policy_from_string", "compiler_version", - "__version__" + + "__version__", ] \ No newline at end of file diff --git a/sdk/python/actra/policy.py b/sdk/python/actra/policy.py index 39ab60a..5a79da4 100644 --- a/sdk/python/actra/policy.py +++ b/sdk/python/actra/policy.py @@ -1,19 +1,45 @@ from pathlib import Path -from os import PathLike -from typing import Optional, Dict, Any, Union +from typing import Optional # Rust binding from .actra import PyActra as _RustActra - -ActionInput = Dict[str, Any] -Decision = Dict[str, Any] -PathType = Union[str, PathLike] +from .types import Action, Actor, Snapshot, Decision, ActionInput, PathType class Policy: """ - Compiled policy object. + Compiled Actra policy. + + A `Policy` represents a fully compiled admission control policy produced + by the Actra compiler. It wraps the Rust policy engine and exposes a + Python-friendly runtime API for evaluating decisions. + + A policy evaluates structured input containing three domains: + + action : operation being requested + actor : identity of the caller + snapshot : external system state + + Example context: + + { + "action": {"type": "refund", "amount": 200}, + "actor": {"role": "support"}, + "snapshot": {"fraud_flag": False} + } + + Policies are deterministic and side-effect free. """ def __init__(self, engine: _RustActra): + """ + Internal constructor. + + Users should not instantiate `Policy` directly. + Instead use the `Actra` loader helpers such as: + + Actra.from_strings(...) + Actra.from_files(...) + Actra.from_directory(...) + """ self._engine = engine # ------------------------------------------------------------------ @@ -24,36 +50,203 @@ def evaluate(self, context: ActionInput) -> Decision: """ Evaluate a policy decision. - Expected input shape: - { - "action": {...}, - "actor": {...}, - "snapshot": {...} - } + Args: + context: + Dictionary containing the evaluation context with + the following structure: + { + "action": {...}, + "actor": {...}, + "snapshot": {...} + } + + Returns: + Decision dictionary produced by the policy engine. + Example result: + { + "effect": "allow" + } + or + { + "effect": "block", + "rule_id": "block_large_refund" + } """ - try: - return self._engine.evaluate(context) - except Exception as e: - raise RuntimeError(f"Actra evaluation failed: {e}") from e + return self._engine.evaluate(context) + + def evaluate_action( + self, + action: Action, + actor: Actor, + snapshot: Snapshot, + ) -> Decision: + """ + Evaluate a policy decision using separate domain inputs. + + This is a convenience wrapper around `evaluate()` that allows + callers to provide action, actor, and snapshot independently. + + Args: + action: + Action object describing the requested operation. + + Example: + {"type": "refund", "amount": 200} + + actor: + Actor identity performing the action. + + Example: + {"role": "support"} + + snapshot: + External system state relevant to the decision. + + Example: + {"fraud_flag": False} + + Returns: + Policy decision dictionary. + """ + return self._engine.evaluate({ + "action": action, + "actor": actor, + "snapshot": snapshot + }) + + def explain(self, context: ActionInput) -> Decision: + """ + Evaluate a decision and print a human-readable explanation. + + This method is intended for debugging, experimentation, and + interactive use (for example in notebooks or REPL sessions). + + It prints: + - action input + - actor input + - snapshot input + - final policy decision + Args: + context: + Evaluation context passed to the policy engine. + Returns: + The policy decision dictionary. + """ + + result = self.evaluate(context) + + print("\nActra Decision") + print("-" * 14) + + for section in ["action", "actor", "snapshot"]: + data = context.get(section, {}) + print(f"\n{section.capitalize()}:") + for k, v in data.items(): + print(f" {k}: {v}") + + print("\nResult:") + for k, v in result.items(): + print(f" {k}: {v}") + + return result def policy_hash(self) -> str: """ - Returns deterministic policy hash. + Return a deterministic hash representing the compiled policy. + + The hash uniquely identifies the compiled schema, policy rules. + + This is useful for: + + - policy versioning + - auditing + - caching + - debugging + + Returns: + A stable string hash for the compiled policy. """ return self._engine.policy_hash() def __repr__(self) -> str: - return f"Policy(policy_hash={self.policy_hash()})" + try: + h = self.policy_hash() + except Exception: + h = "unknown" + return f"Policy(hash={h})" + + def __str__(self) -> str: + return self.__repr__() + + def _repr_html_(self): + """ + Rich HTML representation for Jupyter notebooks. + + Displays a compact policy summary including the policy hash. + """ + try: + policy_hash = self.policy_hash() + except Exception: + policy_hash = "unknown" + + return f""" +
+ Actra Policy
+ Hash: {policy_hash} +
+ """ + + def assert_effect(self, context: ActionInput, expected: str) -> Decision: + """ + Assert that a policy evaluation returns the expected effect. + + This helper is useful in unit tests and CI pipelines to verify + that policies behave as expected. + + Example: + + policy.assert_effect(context, "block") + + Args: + context: + Evaluation context. + + expected: + Expected policy effect ("allow" or "block"). + + Returns: + The policy decision if the assertion succeeds. + + Raises: + AssertionError: + If the actual policy effect differs from the expected value. + """ + result = self.evaluate(context) + actual = result.get("effect") + + if actual != expected: + raise AssertionError( + f"Policy assertion failed.\n" + f"Expected effect: {expected}\n" + f"Actual effect: {actual}\n" + f"Context: {context}" + ) + return result class Actra: """ - Public Python SDK wrapper for the Rust-based Actra engine. + Public Python SDK wrapper for the Actra compiler and policy engine. + + This class provides convenience helpers for loading and compiling + policies from various sources such as: + + - YAML strings + - YAML files + - policy directories - Responsibilities: - - Developer ergonomics - - File loading - - Abstraction over Rust binding + The resulting compiled policy is returned as a `Policy` object, + which can be used for runtime evaluation. """ @staticmethod @@ -63,8 +256,26 @@ def from_strings( governance_yaml: Optional[str] = None, ) -> Policy: """ - Initialize Actra directly from YAML strings. - Suitable for testing and dynamic usage. + Compile an Actra policy directly from YAML strings. + + This method is useful for: + + - testing + - dynamic policy generation + - examples + + Args: + schema_yaml: + YAML string defining the schema. + + policy_yaml: + YAML string defining policy rules. + + governance_yaml: + Optional YAML string defining governance rules. + + Returns: + A compiled `Policy` object ready for evaluation. """ engine = _RustActra(schema_yaml, policy_yaml, governance_yaml) return Policy(engine) @@ -76,7 +287,20 @@ def from_files( governance_path: Optional[PathType] = None, ) -> Policy: """ - Initialize Actra from YAML files. + Compile an Actra policy from YAML files. + + Args: + schema_path: + Path to the schema YAML file. + + policy_path: + Path to the policy YAML file. + + governance_path: + Optional path to the governance YAML file. + + Returns: + A compiled `Policy` object. """ schema_file = Path(schema_path) if not schema_file.is_file(): @@ -86,8 +310,8 @@ def from_files( if not policy_file.is_file(): raise FileNotFoundError(f"Policy file not found: {policy_path}") - schema_yaml = schema_file.read_text(encoding="utf-8") - policy_yaml = policy_file.read_text(encoding="utf-8") + schema_yaml: str = schema_file.read_text(encoding="utf-8") + policy_yaml: str = policy_file.read_text(encoding="utf-8") governance_yaml = None if governance_path is not None: @@ -102,6 +326,51 @@ def from_files( @staticmethod def compiler_version() -> str: """ - Returns compiler version string from Rust core. + Return the Actra compiler version. + + This corresponds to the underlying Rust engine version. + + Returns: + Version string of the Actra compiler. """ return _RustActra.compiler_version() + + @staticmethod + def from_directory(directory: PathType) -> Policy: + """ + Compile a policy from a directory structure. + + The directory must contain: + + schema.yaml + policy.yaml + + Optionally: + + governance.yaml + + Args: + directory: + Directory containing Actra policy files. + + Returns: + A compiled `Policy` object. + """ + directory = Path(directory) + + if not directory.is_dir(): + raise FileNotFoundError(f"Directory not found: {directory}") + + schema_path = directory / "schema.yaml" + policy_path = directory / "policy.yaml" + governance_path = directory / "governance.yaml" + + governance = governance_path if governance_path.exists() else None + + return Actra.from_files( + schema_path, + policy_path, + governance + ) + +__all__ = ["Policy", "Actra"] \ No newline at end of file diff --git a/sdk/python/actra/runtime.py b/sdk/python/actra/runtime.py new file mode 100644 index 0000000..37f7771 --- /dev/null +++ b/sdk/python/actra/runtime.py @@ -0,0 +1,397 @@ +from typing import Tuple, Optional, List +from functools import wraps +import inspect +from .types import ( + Action, + Actor, + Snapshot, + Decision, + EvaluationContext, + Context, + ActionBuilder, + ActorResolver, + SnapshotResolver, + ActionResolver, +) + +class ActraRuntime: + """ + Runtime execution environment for Actra policies. + + `ActraRuntime` orchestrates policy evaluation by resolving runtime + context and invoking the compiled policy engine. + + It acts as the bridge between application code and the policy engine. + + Responsibilities: + + - Resolving actor identity + - Resolving external system state (snapshot) + - Constructing action objects + - Building evaluation context + - Invoking policy evaluation + - Enforcing admission control via decorators + + Typical usage: + + policy = Actra.from_strings(schema_yaml, policy_yaml) + runtime = ActraRuntime(policy) + + runtime.set_actor_resolver(lambda ctx: {"role": "support"}) + runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + + @runtime.admit() + def refund(amount: int): + ... + + Runtime is framework-neutral and can be used with: + + - normal Python functions + - API frameworks + - background workers + - AI agent tool systems + """ + + def __init__(self, policy): + """ + Create a runtime bound to a compiled policy. + + Args: + policy: + A compiled `Policy` instance produced by the Actra compiler. + """ + self.policy = policy + + self._actor_resolver: Optional[ActorResolver] = None + self._snapshot_resolver: Optional[SnapshotResolver] = None + self._action_resolver: Optional[ActionResolver] = None + + # Resolvers + def set_actor_resolver(self, fn: ActorResolver) -> None: + """ + Register a function that resolves the actor identity. + + The actor represents the entity performing the action + (for example a user, service, or agent). + + Args: + fn: + Function with signature: + + fn(ctx) -> dict + + Example: + runtime.set_actor_resolver( + lambda ctx: {"role": "support"} + ) + """ + self._actor_resolver = fn + + def set_snapshot_resolver(self, fn: SnapshotResolver) -> None: + """ + Register a function that resolves external system state. + + Snapshot data represents system state that policies + may use to make decisions. + + Args: + fn: + Function with signature: + + fn(ctx) -> dict + + Example: + + runtime.set_snapshot_resolver( + lambda ctx: {"fraud_flag": False} + ) + """ + self._snapshot_resolver = fn + + def set_action_resolver(self, fn: ActionResolver) -> None: + """ + Register a custom action builder. + + This overrides the default action construction logic. + + Args: + fn: + Function with signature: + + fn(action_type, args, kwargs, ctx) -> dict + + Example: + + runtime.set_action_resolver( + lambda action, args, kwargs, ctx: { + "type": action, + "amount": kwargs["amount"] + } + ) + """ + self._action_resolver = fn + + # Context helpers + def resolve_actor(self, ctx: Context) -> Actor: + """ + Resolve the actor domain. + + Args: + ctx: + Optional execution context supplied by integrations. + + Returns: + Actor dictionary used in policy evaluation. + """ + if self._actor_resolver: + return self._actor_resolver(ctx) + return {} + + def resolve_snapshot(self, ctx: Context) -> Snapshot: + """ + Resolve the snapshot domain. + + Args: + ctx: + Optional execution context supplied by integrations. + + Returns: + Snapshot dictionary used in policy evaluation. + """ + if self._snapshot_resolver: + return self._snapshot_resolver(ctx) + return {} + + # Action construction + + def build_action(self, + action_type: str, + args: Tuple, + kwargs: dict, + ctx: Context, + fields: Optional[List[str]]=None, + builder: Optional[ActionBuilder]=None + ) -> Action: + """ + Construct an Actra action object. + + This method converts application inputs into a structured + action dictionary used for policy evaluation. + + Args: + action_type: + Logical action name (typically the function name). + + args: + Positional arguments passed to the function. + + kwargs: + Keyword arguments passed to the function. + + ctx: + Optional execution context provided by integrations. + + fields: + Optional list of keyword fields to include. + + builder: + Optional custom action builder function. + + Returns: + Dictionary representing the action. + + Example result: + + { + "type": "refund", + "amount": 200 + } + """ + if builder: + return builder(action_type, args, kwargs, ctx) + if self._action_resolver: + return self._action_resolver(action_type, args, kwargs, ctx) + if fields: + action_fields = {k: kwargs[k] for k in fields if k in kwargs} + else: + # Default: include kwargs but ignore internal parameters + action_fields = { + k: v + for k, v in kwargs.items() + if not k.startswith("_") + } + return { + "type": action_type, + **action_fields + } + + # Context assembly + + def build_context(self, action: Action, ctx: Context = None) -> EvaluationContext : + """ + Build the full evaluation context. + + The context combines the three policy domains: + + action + actor + snapshot + + Args: + action: + Action dictionary produced by `build_action`. + + ctx: + Optional execution context. + + Returns: + Context dictionary passed to the policy engine. + """ + actor = self.resolve_actor(ctx) + snapshot = self.resolve_snapshot(ctx) + return { + "action": action, + "actor": actor, + "snapshot": snapshot + } + + # Policy evaluation + def evaluate(self, action: Action, ctx: Context = None) -> Decision: + """ + Evaluate a policy decision for an action. + + Args: + action: + Action dictionary describing the operation. + + ctx: + Optional execution context passed to resolvers. + + Returns: + Decision dictionary returned by the policy engine. + """ + context = self.build_context(action, ctx) + return self.policy.evaluate(context) + + # Admission control decorator + + def admit( + self, + action_type: Optional[str] = None, + fields: Optional[List[str]] = None, + action_builder: Optional[ActionBuilder] = None): + """ + Protect a function with Actra admission control. + + The decorator intercepts the function call, evaluates the policy, + and blocks execution if the decision effect is `"block"`. + + IMPORTANT: + By default, Actra uses the Python function name as the action + type when constructing the policy action. + + Example: + + @runtime.admit() + def support_refund(amount): + ... + + This produces an action: + + {"type": "support_refund", ...} + + If the policy expects a different action name, you must override + it using `action_type`. + + Example: + + @runtime.admit(action_type="refund") + def support_refund(amount): + ... + + This maps the function to the policy action `"refund"`. + + Args: + action_type: + Optional action name override. If not provided, the function + name will be used as the action type. + + fields: + Optional list of keyword arguments to include in the action + object. Useful when functions contain parameters that should + not be exposed to the policy engine. + + action_builder: + Optional custom function used to construct the action object. + + Signature: + + builder(action_type, args, kwargs, ctx) -> dict + + Usage examples: + + Default mapping: + + @runtime.admit() + def refund(amount): + ... + + Field filtering: + + @runtime.admit(fields=["amount"]) + def refund(amount, currency): + ... + + Custom action builder: + + @runtime.admit(action_builder=my_builder) + def refund(...): + ... + """ + def decorator(func): + # Default action type is the Python function name + act = action_type or func.__name__ + is_async = inspect.iscoroutinefunction(func) + + def evaluate_policy(args, kwargs): + # Runtime does not extract context from function arguments. + ctx = None + + action = self.build_action( + act, + args, + kwargs, + ctx, + fields=fields, + builder=action_builder, + ) + + result = self.evaluate(action, ctx) + + if result.get("effect") == "block": + rule = result.get("rule_id") + + if rule: + raise PermissionError( + f"Actra policy blocked action '{act}' (rule: {rule})" + ) + else: + raise PermissionError( + f"Actra policy blocked action '{act}'" + ) + + if is_async: + @wraps(func) + async def wrapper(*args, **kwargs): + evaluate_policy(args, kwargs) + return await func(*args, **kwargs) + else: + @wraps(func) + def wrapper(*args, **kwargs): + evaluate_policy(args, kwargs) + return func(*args, **kwargs) + return wrapper + return decorator + + +__all__ = ["ActraRuntime"] \ No newline at end of file diff --git a/sdk/python/actra/types.py b/sdk/python/actra/types.py new file mode 100644 index 0000000..dcb10b5 --- /dev/null +++ b/sdk/python/actra/types.py @@ -0,0 +1,16 @@ +from typing import Dict, Any, Union, Callable, Tuple +from os import PathLike + +Action = Dict[str, Any] +Actor = Dict[str, Any] +Snapshot = Dict[str, Any] +Decision = Dict[str, Any] +PathType = Union[str, PathLike] +ActionInput = Dict[str, Any] # {"action": Action, "actor": Actor, "snapshot": Snapshot} +Context = Any +EvaluationContext = Dict[str, Any] + +ActionBuilder = Callable[[str, Tuple[Any, ...], Dict[str, Any], Context], Action] +ActorResolver = Callable[[Context], Actor] +SnapshotResolver = Callable[[Context], Snapshot] +ActionResolver = Callable[[str, Tuple[Any, ...], Dict[str, Any], Context], Action] \ No newline at end of file