diff --git a/.github/workflows/py-sdk-build-publish.yml b/.github/workflows/py-sdk-build-publish.yml index 3594464..b733106 100644 --- a/.github/workflows/py-sdk-build-publish.yml +++ b/.github/workflows/py-sdk-build-publish.yml @@ -119,7 +119,7 @@ jobs: publish_test: name: Publish to Test PyPI - if: github.ref != 'refs/heads/main' + if: ${{ github.ref_type == 'branch' && github.ref_name != 'main' }} needs: [build_wheels, build_sdist] runs-on: ubuntu-latest #environment: pypi diff --git a/Cargo.toml b/Cargo.toml index 7487092..5856a63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.4.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 index f550338..c9c3dfe 100644 --- a/examples/python/core-sdk/basic_refund.py +++ b/examples/python/core-sdk/basic_refund.py @@ -8,7 +8,7 @@ and blocks the call if the policy denies it. """ -from actra import Actra +from actra import Actra, ActraPolicyError from actra.runtime import ActraRuntime @@ -121,4 +121,8 @@ def refund(amount: int): print("\n--- Blocked call ---") -refund(amount=1500) \ No newline at end of file +try: + refund(amount=1500) +except ActraPolicyError as e: + print("Refund blocked by policy") + print("Rule:", e.matched_rule ) \ No newline at end of file diff --git a/examples/python/core-sdk/build_action.py b/examples/python/core-sdk/build_action.py new file mode 100644 index 0000000..c356599 --- /dev/null +++ b/examples/python/core-sdk/build_action.py @@ -0,0 +1,116 @@ +""" +Build Action Example + +This example demonstrates how to use `ActraRuntime.build_action` +when evaluating actions outside of decorators + +This pattern is useful for integrations where the application +does not call a protected function, such as: + +- API frameworks +- message queues +- MCP servers +- background workers +""" + +from actra import Actra, 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. Example external input +# ------------------------------------------------------------ +# Imagine this comes from an API request or message queue + +request_data = { + "amount": 200 +} + + +# ------------------------------------------------------------ +# 6. Define a handler signature +# ------------------------------------------------------------ +# build_action uses the function signature to determine which +# fields are valid action parameters. +# +# The function is NOT executed. It is only used for introspection. + +def fake_handler(amount): + pass + + +action = runtime.build_action( + func=fake_handler, + action_type="refund", + args=(), + kwargs=request_data, + ctx=None +) + + +# ------------------------------------------------------------ +# 7. Evaluate decision +# ------------------------------------------------------------ + +decision = runtime.evaluate(action) + +print("Decision:", decision) \ 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 index 65106c8..7d529b6 100644 --- a/examples/python/core-sdk/custom_action_builder.py +++ b/examples/python/core-sdk/custom_action_builder.py @@ -8,7 +8,7 @@ map directly to policy fields. """ -from actra import Actra +from actra import Actra, ActraPolicyError from actra.runtime import ActraRuntime @@ -79,6 +79,7 @@ def build_refund_action(action_type, args, kwargs, ctx): """ Convert application inputs into the policy action object. + Only fields defined in the policy schema should be included """ return { @@ -104,4 +105,9 @@ def refund(amount: int, currency: str): refund(amount=200, currency="USD") print("\nBlocked call") -refund(amount=1500, currency="USD") \ No newline at end of file + +try: + refund(amount=1500, currency="USD") +except ActraPolicyError as e: + print("Refund blocked by policy") + print("Rule:", e.matched_rule) \ 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 index 4202808..674fe5c 100644 --- a/examples/python/core-sdk/custom_actor_snapshot_resolvers.py +++ b/examples/python/core-sdk/custom_actor_snapshot_resolvers.py @@ -2,11 +2,22 @@ Custom Actor and Snapshot Resolvers Example Demonstrates how runtime context can be used to dynamically -resolve actor and system state. +resolve actor identity and external system state. + +Resolvers allow applications to supply policy inputs +from runtime context such as: + +- authenticated user identity +- request metadata +- system state + """ -from actra import Actra, ActraRuntime +from actra import Actra, ActraRuntime, ActraPolicyError +# ------------------------------------------------------------ +# 1. Schema +# ------------------------------------------------------------ schema_yaml = """ version: 1 @@ -25,6 +36,9 @@ fraud_flag: boolean """ +# ------------------------------------------------------------ +# 2. Policy +# ------------------------------------------------------------ policy_yaml = """ version: 1 @@ -50,31 +64,46 @@ effect: block """ +# ------------------------------------------------------------ +# 3. Compile policy +# ------------------------------------------------------------ policy = Actra.from_strings(schema_yaml, policy_yaml) runtime = ActraRuntime(policy) - -# Example request context +# ------------------------------------------------------------ +# 4. Example request context +# ------------------------------------------------------------ class RequestContext: def __init__(self, role, fraud_flag): self.role = role self.fraud_flag = fraud_flag -# Actor resolver +# ------------------------------------------------------------ +# 5. Register resolvers +# ------------------------------------------------------------ + +# Actor resolver extracts identity information + runtime.set_actor_resolver( lambda ctx: {"role": ctx.role} ) -# Snapshot resolver +# Snapshot resolver extracts system state runtime.set_snapshot_resolver( lambda ctx: {"fraud_flag": ctx.fraud_flag} ) +# ------------------------------------------------------------ +# 6. Create runtime context +# ------------------------------------------------------------ ctx = RequestContext(role="support", fraud_flag=False) +# ------------------------------------------------------------ +# 8. Build action +# ------------------------------------------------------------ action = runtime.build_action( action_type="refund", @@ -83,6 +112,9 @@ def __init__(self, role, fraud_flag): ctx=ctx ) -decision = runtime.evaluate(action, ctx) +# ------------------------------------------------------------ +# 9. Evaluate policy +# ------------------------------------------------------------ +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 index 16513ea..509d4fc 100644 --- a/examples/python/core-sdk/fields_filtering.py +++ b/examples/python/core-sdk/fields_filtering.py @@ -2,13 +2,18 @@ Field Filtering Example This example demonstrates how to restrict which parameters -become part of the Actra action object. +become part of the Actra action object -This is useful when functions contain additional arguments -that should not be exposed to the policy engine. +Field filtering is useful when functions contain additional +arguments that should not be exposed to the policy engine + +In this example the function accepts both `amount` and +`currency`, but the policy schema only defines the `amount` +field. The `fields` parameter ensures only the allowed fields +are included in the policy action """ -from actra import Actra +from actra import Actra, ActraPolicyError from actra.runtime import ActraRuntime @@ -85,4 +90,8 @@ def refund(amount: int, currency: str): refund(amount=200, currency="USD") print("\nBlocked call") -refund(amount=1500, currency="USD") \ No newline at end of file +try: + refund(amount=1500, currency="USD") +except ActraPolicyError as e: + print("Refund blocked by policy") + print("Rule:", e.matched_rule) diff --git a/examples/python/core-sdk/manual_runtime_evaluation.py b/examples/python/core-sdk/manual_runtime_evaluation.py index d0016a6..22aee01 100644 --- a/examples/python/core-sdk/manual_runtime_evaluation.py +++ b/examples/python/core-sdk/manual_runtime_evaluation.py @@ -87,12 +87,10 @@ # - message queues # -action = runtime.build_action( - action_type="refund", - args=(), - kwargs={"amount": 200}, - ctx=None -) +action = { + "type": "refund", + "amount": 200 +} # ------------------------------------------------------------ # 7. Evaluate the action @@ -107,12 +105,10 @@ # 8. Blocked example # ------------------------------------------------------------ -blocked_action = runtime.build_action( - action_type="refund", - args=(), - kwargs={"amount": 1500}, - ctx=None -) +blocked_action = { + "type": "refund", + "amount": 1500 +} blocked_result = runtime.evaluate(blocked_action) diff --git a/examples/python/core-sdk/multiple_runtimes.py b/examples/python/core-sdk/multiple_runtimes.py index 0ee718c..84523aa 100644 --- a/examples/python/core-sdk/multiple_runtimes.py +++ b/examples/python/core-sdk/multiple_runtimes.py @@ -7,7 +7,7 @@ Each runtime can enforce a different policy. """ -from actra import Actra +from actra import Actra, ActraPolicyError from actra.runtime import ActraRuntime @@ -156,8 +156,9 @@ def admin_refund(amount: int): try: support_refund(amount=5000) -except PermissionError as e: - print(e) +except ActraPolicyError as e: + print("Support refund blocked") + print("Rule:",e.matched_rule) print("\nAdmin runtime") @@ -166,5 +167,6 @@ def admin_refund(amount: int): try: admin_refund(amount=20000) -except PermissionError as e: - print(e) \ No newline at end of file +except ActraPolicyError as e: + print("Admin refund blocked") + print("Rule:",e.matched_rule) \ No newline at end of file diff --git a/examples/python/runtime/audit_decorator.py b/examples/python/runtime/audit_decorator.py new file mode 100644 index 0000000..980b4ae --- /dev/null +++ b/examples/python/runtime/audit_decorator.py @@ -0,0 +1,63 @@ +""" +Audit Mode Example + +Shows how policy violations can be observed without +blocking execution. +""" + +from actra import Actra, ActraRuntime, DecisionEvent + + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: +""" + +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) +runtime = ActraRuntime(policy) + +def observer(event:DecisionEvent): + print( + f"Decision: {event.effect} " + f"rule={event.matched_rule}" + ) + + +runtime.set_decision_observer(observer) + + +@runtime.audit() +def refund(amount: int): + print("Refund executed:", amount) + + +refund(amount=200) +refund(amount=2000) \ No newline at end of file diff --git a/examples/python/runtime/context_resolver.py b/examples/python/runtime/context_resolver.py new file mode 100644 index 0000000..4238b60 --- /dev/null +++ b/examples/python/runtime/context_resolver.py @@ -0,0 +1,156 @@ +""" +Context Resolver Example + +This example demonstrates how ActraRuntime can extract +execution context from function arguments + +Context resolvers are useful when integrations provide +additional runtime information such as: + +- authenticated user identity +- request metadata +- agent information +- session state + +The context resolver extracts this information and makes +it available to actor and snapshot resolvers +""" + +from actra import Actra, ActraPolicyError +from actra.runtime import ActraRuntime + + +# ------------------------------------------------------------ +# 1. Example application context +# ------------------------------------------------------------ + +class RequestContext: + """ + Example request context used by the application + """ + + def __init__(self, role: str): + self.role = role + + +# ------------------------------------------------------------ +# 2. Schema +# ------------------------------------------------------------ + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: + fraud_flag: boolean +""" + + +# ------------------------------------------------------------ +# 3. Policy +# ------------------------------------------------------------ + +policy_yaml = """ +version: 1 + +rules: + - id: block_large_refund + 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 +""" + + +# ------------------------------------------------------------ +# 4. Compile policy and create runtime +# ------------------------------------------------------------ + +policy = Actra.from_strings(schema_yaml, policy_yaml) +runtime = ActraRuntime(policy) + + +# ------------------------------------------------------------ +# 5. Context resolver +# ------------------------------------------------------------ +# Extract the RequestContext object from function arguments + +def extract_context(args, kwargs): + return kwargs.get("ctx") + + +runtime.set_context_resolver(extract_context) + + +# ------------------------------------------------------------ +# 6. Actor resolver +# ------------------------------------------------------------ +# Use context information to build the actor domain + +def resolve_actor(ctx): + return {"role": ctx.role} + + +runtime.set_actor_resolver(resolve_actor) + + +# ------------------------------------------------------------ +# 7. Snapshot resolver +# ------------------------------------------------------------ + +runtime.set_snapshot_resolver(lambda ctx: {"fraud_flag": False}) + + +# ------------------------------------------------------------ +# 8. Protected function +# ------------------------------------------------------------ + +# IMPORTANT: +# ctx is application context and should not be included +# in the policy action. Therefore we restrict fields to +# ["amount"]. +@runtime.admit(fields=["amount"]) +def refund(amount: int, ctx: RequestContext): + print(f"Refund executed: {amount}") + + +# ------------------------------------------------------------ +# 9. Calls +# ------------------------------------------------------------ + +support_ctx = RequestContext(role="support") + +print("\n--- Allowed call ---") +refund(amount=200, ctx=support_ctx) + + +print("\n--- Blocked call ---") + +try: + refund(amount=2000, ctx=support_ctx) + +except ActraPolicyError as e: + print("Refund blocked by policy") + print("Rule:", e.matched_rule) \ No newline at end of file diff --git a/examples/python/runtime/decision_observer.py b/examples/python/runtime/decision_observer.py new file mode 100644 index 0000000..ea6f03f --- /dev/null +++ b/examples/python/runtime/decision_observer.py @@ -0,0 +1,72 @@ +""" +Decision Observer Example + +Demonstrates how to observe policy decisions using +the DecisionEvent observer mechanism. +""" + +from actra import Actra, ActraRuntime, ActraPolicyError + + +schema_yaml = """ +version: 1 + +actions: + refund: + fields: + amount: number + +actor: + fields: + role: string + +snapshot: + fields: +""" + +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) +runtime = ActraRuntime(policy) + + +runtime.set_actor_resolver(lambda ctx: {"role": "support"}) + + +def observer(event): + print( + f"[Actra] action={event.action_type} " + f"effect={event.effect} " + f"rule={event.matched_rule} " + f"timestamp={event.timestamp} " + f"time={event.duration_ms:.2f}ms" + ) + + +runtime.set_decision_observer(observer) + + +@runtime.admit() +def refund(amount: int): + print("Refund executed:", amount) + +refund(amount=200) +try: + refund(amount=2000) +except ActraPolicyError as e: + print("Blocked Rule:", e.matched_rule ) diff --git a/examples/python/runtime/manual_evaluation_with_events.py b/examples/python/runtime/manual_evaluation_with_events.py new file mode 100644 index 0000000..f60ecf8 --- /dev/null +++ b/examples/python/runtime/manual_evaluation_with_events.py @@ -0,0 +1,62 @@ +""" +Manual Evaluation Example + +Shows how to evaluate actions programmatically while +still emitting DecisionEvent objects. +""" + +from actra import Actra, ActraRuntime, DecisionEvent + +schema_yaml = """ +version: 1 + +actions: + deploy: + fields: + env: string + +actor: + fields: + role: string + +snapshot: + fields: +""" + +policy_yaml = """ +version: 1 + +rules: + - id: block_prod + scope: + action: deploy + when: + subject: + domain: action + field: env + operator: equals + value: + literal: "prod" + effect: block +""" + +policy = Actra.from_strings(schema_yaml, policy_yaml) +runtime = ActraRuntime(policy) + + +def observer(event:DecisionEvent): + print("Decision:", event.effect) + + +runtime.set_decision_observer(observer) + + +action = runtime.build_action( + func=lambda: None, + action_type="deploy", + args=(), + kwargs={"env": "prod"}, + ctx=None +) + +runtime.evaluate(action) \ No newline at end of file diff --git a/sdk/python/actra/__init__.py b/sdk/python/actra/__init__.py index 2bbc29e..facbecb 100644 --- a/sdk/python/actra/__init__.py +++ b/sdk/python/actra/__init__.py @@ -3,6 +3,8 @@ from .policy import Actra, Policy from .runtime import ActraRuntime from .types import Action, Actor, Snapshot, Decision, Context +from .errors import ActraError, ActraPolicyError +from .events import DecisionEvent try: __version__ = version("actra") @@ -17,6 +19,9 @@ "Actra", "Policy", "ActraRuntime", + "ActraError", + "ActraPolicyError", + "DecisionEvent", # Runtime types "Action", diff --git a/sdk/python/actra/errors.py b/sdk/python/actra/errors.py new file mode 100644 index 0000000..035e513 --- /dev/null +++ b/sdk/python/actra/errors.py @@ -0,0 +1,134 @@ +class ActraError(Exception): + """ + Base exception for all Actra-related errors + + All exceptions raised by the Actra Python SDK inherit from + `ActraError`. Applications integrating Actra may catch this + exception to handle any Actra failure generically + + Example: + + try: + refund(amount=5000) + except ActraError as e: + logger.error("Actra failure", exc_info=e) + + Specific subclasses provide additional context depending on + the failure type (for example policy evaluation errors) + """ + pass + + +class ActraPolicyError(ActraError): + """ + Exception raised when an Actra policy blocks an operation + + This error is raised by the `ActraRuntime.admit` decorator + when a policy decision results in a `"block"` effect + + The exception includes structured information describing + the decision and evaluation context to assist debugging, + logging and API responses + + Attributes: + action_type: + The action name evaluated by the policy + + decision: + Decision dictionary returned by the policy engine + Example: + { + "effect": "block", + "rule_id": "support_limit" + } + + context: + Full evaluation context used during policy execution: + { + "action": {...}, + "actor": {...}, + "snapshot": {...} + } + Example: + + try: + refund(amount=2000) + except ActraPolicyError as e: + print("Blocked by rule:", e.rule_id) + print(e.context) + + This structured information allows Actra to integrate cleanly + with APIs, AI agents, logging systems and monitoring tools + """ + + def __init__(self, action_type, decision, context): + """ + Initialize a policy error + + Args: + action_type: + The action evaluated by the policy + + decision: + Decision returned by the policy engine + + context: + Evaluation context containing the action, + actor and snapshot domains + """ + self.action_type = action_type + self.decision = decision + self.context = context + + rule = decision.get("matched_rule") + + if rule: + message = f"Actra policy blocked action '{action_type}' (rule: {rule})" + else: + message = f"Actra policy blocked action '{action_type}'" + + super().__init__(message) + + @property + def matched_rule(self): + """ + Return the identifier of the rule that blocked the action. + + Returns: + The rule identifier if available, otherwise ``None`` + """ + return self.decision.get("matched_rule") + + def to_dict(self): + """ + Return a structured representation of the error + + This method is useful when integrating Actra with APIs, + logging systems, or monitoring tools + + Example: + + except ActraPolicyError as e: + logger.warning("Policy blocked", extra=e.to_dict()) + + Returns: + Dictionary containing the action, decision and context + """ + return { + "action": self.action_type, + "decision": self.decision, + "context": self.context, + } + + def __repr__(self): + """ + Return a developer-friendly representation of the error + + The representation includes the action type and decision + data to simplify debugging in logs and interactive sessions + """ + return ( + f"ActraPolicyError(" + f"action_type={self.action_type!r}, " + f"decision={self.decision!r})" + ) \ No newline at end of file diff --git a/sdk/python/actra/events.py b/sdk/python/actra/events.py new file mode 100644 index 0000000..403063b --- /dev/null +++ b/sdk/python/actra/events.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from .types import Action, Decision, EvaluationContext + + +@dataclass +class DecisionEvent: + """ + Structured event emitted after every policy evaluation + + This object represents the outcome of a policy evaluation and + can be consumed by logging systems, metrics collectors, or + audit pipelines + + Attributes: + action: + The action object evaluated by the policy engine + + decision: + Decision returned by the policy engine + + context: + Full evaluation context including action, actor and snapshot + + timestamp: + Time when the evaluation occurred + + duration_ms: + Time taken to evaluate the policy in milliseconds + """ + + action: Action + decision: Decision + context: EvaluationContext + + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + duration_ms: float = 0.0 + + @property + def effect(self) -> str: + """Return policy effect (`allow` or `block`)""" + return self.decision.get("effect") + + @property + def matched_rule(self) -> Optional[str]: + """Return rule identifier if a rule triggered.""" + return self.decision.get("matched_rule") + + @property + def is_blocked(self) -> bool: + """Return True if the policy blocked the action.""" + return self.effect == "block" + + @property + def action_type(self) -> str: + """Return the evaluated action type.""" + return self.action.get("type", "unknown") \ No newline at end of file diff --git a/sdk/python/actra/runtime.py b/sdk/python/actra/runtime.py index 37f7771..a0cf10d 100644 --- a/sdk/python/actra/runtime.py +++ b/sdk/python/actra/runtime.py @@ -1,6 +1,9 @@ -from typing import Tuple, Optional, List +from typing import Tuple, Optional, List, Dict, Any, Callable from functools import wraps import inspect +from datetime import datetime +import time + from .types import ( Action, Actor, @@ -12,7 +15,12 @@ ActorResolver, SnapshotResolver, ActionResolver, + ContextResolver, + ActionTypeResolver, ) +from .errors import ActraPolicyError +from .policy import Policy +from .events import DecisionEvent class ActraRuntime: """ @@ -52,7 +60,7 @@ def refund(amount: int): - AI agent tool systems """ - def __init__(self, policy): + def __init__(self, policy: Policy) -> None: """ Create a runtime bound to a compiled policy. @@ -65,8 +73,30 @@ def __init__(self, policy): self._actor_resolver: Optional[ActorResolver] = None self._snapshot_resolver: Optional[SnapshotResolver] = None self._action_resolver: Optional[ActionResolver] = None - + self._context_resolver: Optional[ContextResolver] = None + self._action_type_resolver: Optional[ActionTypeResolver] = None + self._decision_observer: Optional[Callable[[DecisionEvent], None]] = None + # Resolvers + def set_decision_observer(self, fn: Callable[[DecisionEvent], None]) -> None: + """ + Register an observer invoked after every policy evaluation + + The observer receives a `DecisionEvent` object containing + the evaluated action, decision result, and evaluation context + + This mechanism allows developers to integrate Actra with + logging, metrics, auditing or monitoring systems + + Example: + + def observer(event): + print(event.effect, event.rule_id) + + runtime.set_decision_observer(observer) + """ + self._decision_observer = fn + def set_actor_resolver(self, fn: ActorResolver) -> None: """ Register a function that resolves the actor identity. @@ -130,6 +160,25 @@ def set_action_resolver(self, fn: ActionResolver) -> None: ) """ self._action_resolver = fn + + def set_context_resolver(self, fn: ContextResolver) -> None: + """ + Register a function that extracts execution context + from function arguments. + + Signature: + fn(args, kwargs) -> ctx + """ + self._context_resolver = fn + + def set_action_type_resolver(self, fn: ActionTypeResolver) -> None: + """ + Register a function that determines the action type dynamically. + + Signature: + fn(func, args, kwargs) -> str + """ + self._action_type_resolver = fn # Context helpers def resolve_actor(self, ctx: Context) -> Actor: @@ -161,44 +210,154 @@ def resolve_snapshot(self, ctx: Context) -> Snapshot: if self._snapshot_resolver: return self._snapshot_resolver(ctx) return {} + + def resolve_context(self, args: Tuple, kwargs: Dict[str, Any]) -> Optional[Context]: + """ + Resolve execution context from function inputs + + Integrations (such as MCP, API frameworks or agents) may provide + context objects through a custom resolver + + Args: + args: + Positional arguments passed to the function + + kwargs: + Keyword arguments passed to the function + + Returns: + Context object used during policy evaluation, or None. + + Context resolution order: + + 1. Explicit context resolver + 2. `ctx` keyword argument + 3. No context + """ + # Custom resolver (integration configured) + if self._context_resolver: + return self._context_resolver(args, kwargs) + + # Automatic detection + if "ctx" in kwargs: + return kwargs["ctx"] + + return None + + def resolve_action_type( + self, + func: Callable, + args: Tuple, + kwargs: Dict[str, Any], + action_type: Optional[str] + ) -> str: + """ + Determine the action type used for policy evaluation + + The action type may be: + + 1. Explicitly provided via the decorator + 2. Determined by a configured action type resolver + 3. Derived from the function name + + Args: + func: + The protected function + + args: + Positional arguments passed to the function + + kwargs: + Keyword arguments passed to the function + + action_type: + Optional action type provided to the decorator + + Returns: + The resolved action type string + """ + if action_type: + return action_type + + if self._action_type_resolver: + return self._action_type_resolver(func, args, kwargs) + + return func.__name__ + + + # Emit decison events + def _emit_decision_event(self, decision, action, context, duration_ms): + if not self._decision_observer: + return + + event = DecisionEvent( + action=action, + decision=decision, + context=context, + duration_ms=duration_ms + ) + + self._decision_observer(event) # Action construction def build_action(self, action_type: str, args: Tuple, - kwargs: dict, + kwargs: Dict[str, Any], ctx: Context, + func: Optional[Callable] = None, fields: Optional[List[str]]=None, - builder: Optional[ActionBuilder]=None + action_builder: Optional[ActionBuilder]=None ) -> Action: """ - Construct an Actra action object. + Construct an Actra action object + + This method converts application inputs into the structured + action dictionary used for policy evaluation - This method converts application inputs into a structured - action dictionary used for policy evaluation. + The `func` parameter is used for signature introspection. + Actra inspects the function signature to determine which + parameters should be included in the action object + + This prevents internal parameters (such as request context, + framework metadata, etc.) from leaking into the policy engine Args: + func: + Optional Function whose signature defines the allowed action fields. + The function is **not executed**. It is only inspected to + determine valid parameter names + + When provided, Actra inspects the function parameters + to determine which fields should be included in the + action object. + + Integrations that do not have a handler function + (for example APIs, MCP tools, message queues) may + pass `None`. + action_type: - Logical action name (typically the function name). + Logical action name used for policy evaluation args: - Positional arguments passed to the function. + Positional arguments supplied to the function kwargs: - Keyword arguments passed to the function. + Keyword arguments supplied to the function ctx: - Optional execution context provided by integrations. + Optional execution context used by resolvers fields: - Optional list of keyword fields to include. + Optional list restricting which keyword arguments are + included in the action object - builder: - Optional custom action builder function. + action_builder: + Optional custom function used to build the action Returns: - Dictionary representing the action. + Action dictionary used for policy evaluation Example result: @@ -207,19 +366,25 @@ def build_action(self, "amount": 200 } """ - if builder: - return builder(action_type, args, kwargs, ctx) + if action_builder: + return action_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("_") - } + if func: + sig = inspect.signature(func) + allowed_fields = set(sig.parameters) + action_fields = { + k: v + for k, v in kwargs.items() + if k in allowed_fields + } + else: + # No function available — trust provided kwargs + action_fields = dict(kwargs) return { "type": action_type, **action_fields @@ -255,6 +420,153 @@ def build_context(self, action: Action, ctx: Context = None) -> EvaluationContex "snapshot": snapshot } + def _enforce_policy( + self, + func: Callable, + args: Tuple, + kwargs: Dict[str, Any], + action_type: Optional[str], + fields: Optional[List[str]], + action_builder: Optional[ActionBuilder] + ) -> None: + """ + Evaluate the Actra policy before executing the wrapped function + + This method performs the full admission control flow: + + 1. Resolve execution context + 2. Resolve the action type + 3. Build the action object + 4. Evaluate the policy decision + 5. Raise `ActraPolicyError` if the decision blocks execution + + This method is used internally by the `admit` decorator + """ + ctx = self.resolve_context(args, kwargs) + act = self.resolve_action_type(func, args, kwargs, action_type) + action = self.build_action( + act, + args, + kwargs, + ctx, + func=func, + fields=fields, + action_builder=action_builder + ) + context = self.build_context(action, ctx) + result = self.evaluate(action, ctx) + + if result.get("effect") == "block": + raise ActraPolicyError( + action_type=act, + decision=result, + context=context + ) + + + def explain(self, action: Action, ctx: Context = None) -> Decision: + """ + Evaluate a policy decision and print a human-readable explanation + + This method behaves similarly to `evaluate()` but delegates to + `Policy.explain()` so that the full evaluation context and decision + are displayed. + + It is useful for: + + - debugging policies + - interactive experimentation + - understanding why a decision was made + + Args: + action: + Action dictionary describing the operation + + ctx: + Optional execution context passed to resolvers + + Returns: + The policy decision dictionary + """ + + context = self.build_context(action, ctx) + return self.policy.explain(context) + + def explain_call(self, func: Callable, *args, **kwargs) -> Decision: + """ + Explain the policy decision for a function call without executing it. + + This helper reconstructs the policy evaluation flow used by the + `@runtime.admit` decorator and prints a human-readable explanation + of the decision. + + It is particularly useful for: + + - debugging policy behavior + - understanding why a rule triggered + - testing policy inputs interactively + - verifying runtime resolvers + + The method performs the following steps: + + 1. Resolve execution context from the function arguments + 2. Determine the action type + 3. Build the Actra action object + 4. Construct the full evaluation context + 5. Invoke `Policy.explain()` to display the decision + + Unlike the decorator, this method does not execute the function. + + Args: + func: + The function protected by Actra admission control. + + *args: + Positional arguments that would be passed to the function. + + **kwargs: + Keyword arguments that would be passed to the function. + + Returns: + Decision dictionary returned by the policy engine. + + Example: + + runtime.explain_call(refund, amount=1500) + + Example output: + + Actra Decision + -------------- + + Action: + type: refund + amount: 1500 + + Actor: + role: support + + Snapshot: + fraud_flag: False + + Result: + effect: block + rule_id: block_large_refund + """ + + ctx = self.resolve_context(args, kwargs) + act = self.resolve_action_type(func, args, kwargs, None) + + action = self.build_action( + func, + act, + args, + kwargs, + ctx + ) + + return self.explain(action, ctx) + # Policy evaluation def evaluate(self, action: Action, ctx: Context = None) -> Decision: """ @@ -268,10 +580,18 @@ def evaluate(self, action: Action, ctx: Context = None) -> Decision: Optional execution context passed to resolvers. Returns: - Decision dictionary returned by the policy engine. + Decision dictionary returned by the policy engine and emits DecisionEvent """ context = self.build_context(action, ctx) - return self.policy.evaluate(context) + start = time.perf_counter() + + decision = self.policy.evaluate(context) + + duration_ms = (time.perf_counter() - start) * 1000 + + self._emit_decision_event(decision, action, context, duration_ms) + + return decision # Admission control decorator @@ -349,46 +669,112 @@ def refund(...): ... """ def decorator(func): - # Default action type is the Python function name - act = action_type or func.__name__ is_async = inspect.iscoroutinefunction(func) + if is_async: + @wraps(func) + async def wrapper(*args, **kwargs): + self._enforce_policy( + func, + args, + kwargs, + action_type, + fields, + action_builder + ) + return await func(*args, **kwargs) + else: + @wraps(func) + def wrapper(*args, **kwargs): + self._enforce_policy( + func, + args, + kwargs, + action_type, + fields, + action_builder + ) + return func(*args, **kwargs) + return wrapper + return decorator - def evaluate_policy(args, kwargs): - # Runtime does not extract context from function arguments. - ctx = None + # Audit Policy + def audit( + self, + action_type: Optional[str] = None, + fields: Optional[List[str]] = None, + action_builder: Optional[ActionBuilder] = None + ): + """ + Observe policy decisions without enforcing them - action = self.build_action( - act, - args, - kwargs, - ctx, - fields=fields, - builder=action_builder, - ) + The `audit` decorator evaluates the policy before executing the + function but never blocks execution, even if the policy decision + is `"block"` - result = self.evaluate(action, ctx) + This mode is useful for: - if result.get("effect") == "block": - rule = result.get("rule_id") + - auditing policy violations + - monitoring rule triggers + - debugging policy behavior + - gradual policy rollout - if rule: - raise PermissionError( - f"Actra policy blocked action '{act}' (rule: {rule})" - ) - else: - raise PermissionError( - f"Actra policy blocked action '{act}'" - ) + Policy decisions still emit `DecisionEvent` objects through the + runtime observer mechanism + + Example: + + @runtime.audit(action_type="refund") + def refund(amount): + ... + + Args: + action_type: + Optional override for the action name + + fields: + Optional list of keyword arguments to include in the action + + action_builder: + Optional custom function used to construct the action object + + Returns: + Decorated function that evaluates policy but never blocks execution + """ + + def decorator(func): + is_async = inspect.iscoroutinefunction(func) if is_async: @wraps(func) async def wrapper(*args, **kwargs): - evaluate_policy(args, kwargs) + try: + self._enforce_policy( + func, + args, + kwargs, + action_type, + fields, + action_builder + ) + except ActraPolicyError: + # Ignore block in audit mode + pass return await func(*args, **kwargs) else: @wraps(func) def wrapper(*args, **kwargs): - evaluate_policy(args, kwargs) + try: + self._enforce_policy( + func, + args, + kwargs, + action_type, + fields, + action_builder + ) + except ActraPolicyError: + # Ignore block in audit mode + pass return func(*args, **kwargs) return wrapper return decorator diff --git a/sdk/python/actra/types.py b/sdk/python/actra/types.py index dcb10b5..93633bb 100644 --- a/sdk/python/actra/types.py +++ b/sdk/python/actra/types.py @@ -13,4 +13,6 @@ 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 +ActionResolver = Callable[[str, Tuple[Any, ...], Dict[str, Any], Context], Action] +ContextResolver = Callable[[Tuple, Dict[str, Any]], Context] +ActionTypeResolver = Callable[[Callable, Tuple, Dict[str, Any]], str] \ No newline at end of file