planar 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,388 @@
|
|
1
|
+
import uuid
|
2
|
+
from contextlib import asynccontextmanager
|
3
|
+
from contextvars import ContextVar
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from enum import Enum
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Any, TypedDict, cast
|
8
|
+
|
9
|
+
from cedarpy import (
|
10
|
+
AuthzResult,
|
11
|
+
Decision,
|
12
|
+
format_policies,
|
13
|
+
is_authorized,
|
14
|
+
)
|
15
|
+
from fastapi import HTTPException
|
16
|
+
from pydantic import BaseModel
|
17
|
+
|
18
|
+
from planar.logging import get_logger
|
19
|
+
from planar.security.auth_context import Principal, get_current_principal
|
20
|
+
|
21
|
+
logger = get_logger(__name__)
|
22
|
+
|
23
|
+
# Context variable for the current authorization service
|
24
|
+
policy_service_var: ContextVar["PolicyService | None"] = ContextVar(
|
25
|
+
"policy_service", default=None
|
26
|
+
)
|
27
|
+
|
28
|
+
|
29
|
+
def get_policy_service() -> "PolicyService | None":
|
30
|
+
"""
|
31
|
+
Get the current authorization service from context.
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
The current PolicyService or None if not set.
|
35
|
+
"""
|
36
|
+
return policy_service_var.get()
|
37
|
+
|
38
|
+
|
39
|
+
def set_policy_service(policy_service: "PolicyService | None") -> Any:
|
40
|
+
"""
|
41
|
+
Set the current authorization service in context.
|
42
|
+
|
43
|
+
Args:
|
44
|
+
policy_service: The authorization service to set.
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
A token that can be used to reset the context.
|
48
|
+
"""
|
49
|
+
return policy_service_var.set(policy_service)
|
50
|
+
|
51
|
+
|
52
|
+
@asynccontextmanager
|
53
|
+
async def policy_service_context(policy_service: "PolicyService | None"):
|
54
|
+
"""Context manager for setting up and tearing down authorization service context"""
|
55
|
+
token = set_policy_service(policy_service)
|
56
|
+
try:
|
57
|
+
yield policy_service
|
58
|
+
finally:
|
59
|
+
policy_service_var.reset(token)
|
60
|
+
|
61
|
+
|
62
|
+
class WorkflowAction(str, Enum):
|
63
|
+
"""Actions that can be performed on a workflow."""
|
64
|
+
|
65
|
+
WORKFLOW_LIST = "Workflow::List"
|
66
|
+
WORKFLOW_VIEW_DETAILS = "Workflow::ViewDetails"
|
67
|
+
WORKFLOW_RUN = "Workflow::Run"
|
68
|
+
WORKFLOW_CANCEL = "Workflow::Cancel"
|
69
|
+
|
70
|
+
|
71
|
+
class AgentAction(str, Enum):
|
72
|
+
"""Actions that can be performed on an agent."""
|
73
|
+
|
74
|
+
AGENT_LIST = "Agent::List"
|
75
|
+
AGENT_VIEW_DETAILS = "Agent::ViewDetails"
|
76
|
+
AGENT_RUN = "Agent::Run"
|
77
|
+
AGENT_UPDATE = "Agent::Update"
|
78
|
+
AGENT_SIMULATE = "Agent::Simulate"
|
79
|
+
|
80
|
+
|
81
|
+
class RuleAction(str, Enum):
|
82
|
+
"""Actions that can be performed on rules."""
|
83
|
+
|
84
|
+
RULE_LIST = "Rule::List"
|
85
|
+
RULE_VIEW_DETAILS = "Rule::ViewDetails"
|
86
|
+
RULE_UPDATE = "Rule::Update"
|
87
|
+
RULE_SIMULATE = "Rule::Simulate"
|
88
|
+
|
89
|
+
|
90
|
+
class ResourceType(str, Enum):
|
91
|
+
PRINCIPAL = "Principal"
|
92
|
+
WORKFLOW = "Workflow"
|
93
|
+
ENTITY = "Entity"
|
94
|
+
AGENT = "Agent"
|
95
|
+
Rule = "Rule"
|
96
|
+
|
97
|
+
|
98
|
+
class EntityIdentifier(TypedDict):
|
99
|
+
type: str
|
100
|
+
id: str
|
101
|
+
|
102
|
+
|
103
|
+
class EntityUid(TypedDict):
|
104
|
+
__entity: EntityIdentifier
|
105
|
+
|
106
|
+
|
107
|
+
class EntityDict(TypedDict):
|
108
|
+
uid: EntityUid
|
109
|
+
attrs: dict
|
110
|
+
parents: list[EntityIdentifier]
|
111
|
+
|
112
|
+
|
113
|
+
@dataclass(frozen=True, slots=True)
|
114
|
+
class AgentResource:
|
115
|
+
"""`id=None` means “any agent” (wild-card)."""
|
116
|
+
|
117
|
+
id: str | None = None
|
118
|
+
|
119
|
+
|
120
|
+
@dataclass(frozen=True, slots=True)
|
121
|
+
class WorkflowResource:
|
122
|
+
"""`name=None` means “any workflow”."""
|
123
|
+
|
124
|
+
function_name: str | None = None
|
125
|
+
|
126
|
+
|
127
|
+
@dataclass(frozen=True, slots=True)
|
128
|
+
class RuleResource:
|
129
|
+
rule_name: str | None = None
|
130
|
+
|
131
|
+
|
132
|
+
ResourceDescriptor = AgentResource | WorkflowResource | RuleResource
|
133
|
+
|
134
|
+
|
135
|
+
class CedarEntity(BaseModel):
|
136
|
+
resource_type: ResourceType
|
137
|
+
resource_key: str
|
138
|
+
resource_attributes: dict[str, Any] = {}
|
139
|
+
|
140
|
+
def to_dict(self) -> EntityDict:
|
141
|
+
role = self.resource_attributes.get("role", None)
|
142
|
+
parents = []
|
143
|
+
if role is not None:
|
144
|
+
parents.append({"type": "Role", "id": role})
|
145
|
+
|
146
|
+
return {
|
147
|
+
"uid": {
|
148
|
+
"__entity": {
|
149
|
+
"type": self.resource_type.value,
|
150
|
+
"id": str(self.resource_attributes[self.resource_key]),
|
151
|
+
}
|
152
|
+
},
|
153
|
+
"attrs": {
|
154
|
+
k: v for k, v in self.resource_attributes.items() if v is not None
|
155
|
+
},
|
156
|
+
"parents": parents,
|
157
|
+
}
|
158
|
+
|
159
|
+
@property
|
160
|
+
def id(self) -> str:
|
161
|
+
"""
|
162
|
+
Returns the identifier value for this CedarEntity, based on its resource_key.
|
163
|
+
|
164
|
+
Sometimes, such as when authorizing on a list of resources, there is no id present
|
165
|
+
for a given resource_key. In this case, we return the string "None".
|
166
|
+
"""
|
167
|
+
return str(self.resource_attributes.get(self.resource_key))
|
168
|
+
|
169
|
+
@staticmethod
|
170
|
+
def from_principal(principal: Principal) -> "CedarEntity":
|
171
|
+
"""Create a CedarEntity instance from principal data.
|
172
|
+
|
173
|
+
Args:
|
174
|
+
principal: Principal instance
|
175
|
+
|
176
|
+
Returns:
|
177
|
+
CedarEntity: A new CedarEntity instance
|
178
|
+
"""
|
179
|
+
return CedarEntity(
|
180
|
+
resource_type=ResourceType.PRINCIPAL,
|
181
|
+
resource_key="sub",
|
182
|
+
resource_attributes=principal.model_dump(),
|
183
|
+
)
|
184
|
+
|
185
|
+
@staticmethod
|
186
|
+
def from_workflow(function_name: str | None) -> "CedarEntity":
|
187
|
+
"""Create a CedarEntity instance from workflow data."""
|
188
|
+
return CedarEntity(
|
189
|
+
resource_type=ResourceType.WORKFLOW,
|
190
|
+
resource_key="function_name",
|
191
|
+
resource_attributes={"function_name": function_name},
|
192
|
+
)
|
193
|
+
|
194
|
+
@staticmethod
|
195
|
+
def from_agent(agent_id: str | None) -> "CedarEntity":
|
196
|
+
"""Create a CedarEntity instance from agent data."""
|
197
|
+
return CedarEntity(
|
198
|
+
resource_type=ResourceType.AGENT,
|
199
|
+
resource_key="agent_id",
|
200
|
+
resource_attributes={"agent_id": agent_id},
|
201
|
+
)
|
202
|
+
|
203
|
+
@staticmethod
|
204
|
+
def from_rule(rule_name: str | None) -> "CedarEntity":
|
205
|
+
"""Create a CedarEntity instance from rule data"""
|
206
|
+
return CedarEntity(
|
207
|
+
resource_type=ResourceType.Rule,
|
208
|
+
resource_key="rule_name",
|
209
|
+
resource_attributes={"rule_name": rule_name},
|
210
|
+
)
|
211
|
+
|
212
|
+
|
213
|
+
class PolicyService:
|
214
|
+
"""Service for managing and evaluating Authorization policies."""
|
215
|
+
|
216
|
+
def __init__(self, policy_file_path: str | None = None) -> None:
|
217
|
+
"""Initialize the Cedar policy service.
|
218
|
+
|
219
|
+
Args:
|
220
|
+
policy_file_path: Path to the Cedar policy file. If not provided,
|
221
|
+
will look for 'policies.cedar' in the current directory.
|
222
|
+
"""
|
223
|
+
self.policy_file_path = (
|
224
|
+
policy_file_path or "planar/security/default_policies.cedar"
|
225
|
+
)
|
226
|
+
self.policies = self._load_policies()
|
227
|
+
|
228
|
+
def _load_policies(self) -> str:
|
229
|
+
"""Load Cedar policies from the specified file."""
|
230
|
+
try:
|
231
|
+
policy = Path(self.policy_file_path).read_text()
|
232
|
+
formatted_policy = format_policies(policy)
|
233
|
+
return formatted_policy
|
234
|
+
except FileNotFoundError:
|
235
|
+
raise FileNotFoundError(f"Policy file not found: {self.policy_file_path}")
|
236
|
+
|
237
|
+
def _get_relevant_role_entities(
|
238
|
+
self, principal_entity: EntityDict
|
239
|
+
) -> list[EntityDict]:
|
240
|
+
member_role_entity_id: EntityIdentifier = {
|
241
|
+
"type": "Role",
|
242
|
+
"id": "member",
|
243
|
+
}
|
244
|
+
|
245
|
+
member_role_entity: EntityDict = {
|
246
|
+
"uid": {
|
247
|
+
"__entity": member_role_entity_id,
|
248
|
+
},
|
249
|
+
"attrs": {},
|
250
|
+
"parents": [],
|
251
|
+
}
|
252
|
+
|
253
|
+
admin_role_entity_id: EntityIdentifier = {
|
254
|
+
"type": "Role",
|
255
|
+
"id": "admin",
|
256
|
+
}
|
257
|
+
|
258
|
+
admin_role_entity: EntityDict = {
|
259
|
+
"uid": {"__entity": admin_role_entity_id},
|
260
|
+
"attrs": {},
|
261
|
+
"parents": [member_role_entity_id],
|
262
|
+
}
|
263
|
+
|
264
|
+
for parent in principal_entity["parents"]:
|
265
|
+
if parent["type"] == "Role" and parent["id"] == "admin":
|
266
|
+
return [admin_role_entity, member_role_entity]
|
267
|
+
elif parent["type"] == "Role" and parent["id"] == "member":
|
268
|
+
return [member_role_entity]
|
269
|
+
|
270
|
+
return []
|
271
|
+
|
272
|
+
def is_allowed(
|
273
|
+
self,
|
274
|
+
principal: CedarEntity,
|
275
|
+
action: str | WorkflowAction | AgentAction | RuleAction,
|
276
|
+
resource: CedarEntity,
|
277
|
+
) -> bool:
|
278
|
+
"""Check if the principal is permitted to perform the action on the resource.
|
279
|
+
|
280
|
+
Args:
|
281
|
+
principal: Dictionary containing principal data with all required fields
|
282
|
+
action: The action to perform (e.g., "Workflow::Run")
|
283
|
+
resource_type: Type of the resource (e.g., "Workflow", "DomainModel")
|
284
|
+
resource_data: Dictionary containing resource data with all required fields
|
285
|
+
|
286
|
+
Returns:
|
287
|
+
bool: True if the action is permitted, False otherwise
|
288
|
+
"""
|
289
|
+
# Create principal and resource entities
|
290
|
+
principal_entity = principal.to_dict()
|
291
|
+
resource_entity = resource.to_dict()
|
292
|
+
|
293
|
+
if (
|
294
|
+
isinstance(action, WorkflowAction)
|
295
|
+
or isinstance(action, AgentAction)
|
296
|
+
or isinstance(action, RuleAction)
|
297
|
+
):
|
298
|
+
action = f'Action::"{action.value}"'
|
299
|
+
else:
|
300
|
+
action = f'Action::"{action}"'
|
301
|
+
|
302
|
+
# Create request with principal and resource entities
|
303
|
+
request = {
|
304
|
+
"principal": f'Principal::"{principal.id}"',
|
305
|
+
"action": f"{action}",
|
306
|
+
"resource": f'{resource.resource_type.value}::"{resource.id}"',
|
307
|
+
}
|
308
|
+
|
309
|
+
# Add entities for this request
|
310
|
+
entities = [
|
311
|
+
principal_entity,
|
312
|
+
resource_entity,
|
313
|
+
*self._get_relevant_role_entities(principal_entity),
|
314
|
+
]
|
315
|
+
|
316
|
+
# Log the authorization request
|
317
|
+
auth_request_uuid = str(uuid.uuid4())
|
318
|
+
|
319
|
+
logger.info(
|
320
|
+
"authorization request",
|
321
|
+
uuid=auth_request_uuid,
|
322
|
+
principal=principal.id,
|
323
|
+
action=action,
|
324
|
+
resource=resource.id,
|
325
|
+
)
|
326
|
+
|
327
|
+
authz_result = is_authorized(request, self.policies, cast(list[dict], entities))
|
328
|
+
|
329
|
+
match authz_result:
|
330
|
+
case AuthzResult(decision=Decision.Allow):
|
331
|
+
logger.info("authorization decision: allow", uuid=auth_request_uuid)
|
332
|
+
return True
|
333
|
+
case _:
|
334
|
+
logger.warning(
|
335
|
+
"authorization decision: deny",
|
336
|
+
uuid=auth_request_uuid,
|
337
|
+
reasons=authz_result.diagnostics.reasons,
|
338
|
+
errors=authz_result.diagnostics.errors,
|
339
|
+
)
|
340
|
+
return False
|
341
|
+
|
342
|
+
def reload_policies(self) -> None:
|
343
|
+
"""Reload policies from the policy file."""
|
344
|
+
self.policies = self._load_policies()
|
345
|
+
|
346
|
+
|
347
|
+
def validate_authorization_for(
|
348
|
+
resource_descriptor: ResourceDescriptor,
|
349
|
+
action: WorkflowAction | AgentAction | RuleAction,
|
350
|
+
):
|
351
|
+
authz_service = get_policy_service()
|
352
|
+
|
353
|
+
if not authz_service:
|
354
|
+
logger.warning("No authorization service configured, skipping authorization")
|
355
|
+
return
|
356
|
+
|
357
|
+
entity: CedarEntity | None = None
|
358
|
+
|
359
|
+
match action:
|
360
|
+
case WorkflowAction():
|
361
|
+
if isinstance(resource_descriptor, WorkflowResource):
|
362
|
+
entity = CedarEntity.from_workflow(resource_descriptor.function_name)
|
363
|
+
case AgentAction():
|
364
|
+
if isinstance(resource_descriptor, AgentResource):
|
365
|
+
entity = CedarEntity.from_agent(resource_descriptor.id)
|
366
|
+
case RuleAction():
|
367
|
+
if isinstance(resource_descriptor, RuleResource):
|
368
|
+
entity = CedarEntity.from_rule(resource_descriptor.rule_name)
|
369
|
+
case _:
|
370
|
+
raise ValueError(f"Invalid action type: {action}")
|
371
|
+
|
372
|
+
if not entity:
|
373
|
+
raise ValueError(
|
374
|
+
f"Invalid resource descriptor {type(resource_descriptor).__name__} for action {action}"
|
375
|
+
)
|
376
|
+
|
377
|
+
# Get current principal and check authorization on current resource
|
378
|
+
principal: Principal | None = get_current_principal()
|
379
|
+
if not principal:
|
380
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
381
|
+
if not authz_service.is_allowed(
|
382
|
+
CedarEntity.from_principal(principal),
|
383
|
+
action,
|
384
|
+
entity,
|
385
|
+
):
|
386
|
+
raise HTTPException(
|
387
|
+
status_code=403, detail="Not authorized to perform this action"
|
388
|
+
)
|
@@ -0,0 +1,77 @@
|
|
1
|
+
permit (
|
2
|
+
principal,
|
3
|
+
action == Action::"Workflow::List",
|
4
|
+
resource
|
5
|
+
);
|
6
|
+
|
7
|
+
permit (
|
8
|
+
principal,
|
9
|
+
action == Action::"Workflow::ViewDetails",
|
10
|
+
resource
|
11
|
+
);
|
12
|
+
|
13
|
+
permit (
|
14
|
+
principal,
|
15
|
+
action == Action::"Workflow::Run",
|
16
|
+
resource
|
17
|
+
);
|
18
|
+
|
19
|
+
permit (
|
20
|
+
principal,
|
21
|
+
action == Action::"Workflow::Cancel",
|
22
|
+
resource
|
23
|
+
);
|
24
|
+
|
25
|
+
permit (
|
26
|
+
principal,
|
27
|
+
action == Action::"Agent::List",
|
28
|
+
resource
|
29
|
+
);
|
30
|
+
|
31
|
+
permit (
|
32
|
+
principal,
|
33
|
+
action == Action::"Agent::ViewDetails",
|
34
|
+
resource
|
35
|
+
);
|
36
|
+
|
37
|
+
permit (
|
38
|
+
principal,
|
39
|
+
action == Action::"Agent::Run",
|
40
|
+
resource
|
41
|
+
);
|
42
|
+
|
43
|
+
permit (
|
44
|
+
principal,
|
45
|
+
action == Action::"Agent::Update",
|
46
|
+
resource
|
47
|
+
);
|
48
|
+
|
49
|
+
permit (
|
50
|
+
principal,
|
51
|
+
action == Action::"Agent::Simulate",
|
52
|
+
resource
|
53
|
+
);
|
54
|
+
|
55
|
+
permit (
|
56
|
+
principal in Role::"admin",
|
57
|
+
action == Action::"Rule::Update",
|
58
|
+
resource
|
59
|
+
);
|
60
|
+
|
61
|
+
permit (
|
62
|
+
principal,
|
63
|
+
action == Action::"Rule::List",
|
64
|
+
resource
|
65
|
+
);
|
66
|
+
|
67
|
+
permit (
|
68
|
+
principal,
|
69
|
+
action == Action::"Rule::ViewDetails",
|
70
|
+
resource
|
71
|
+
);
|
72
|
+
|
73
|
+
permit (
|
74
|
+
principal,
|
75
|
+
action == Action::"Rule::Simulate",
|
76
|
+
resource
|
77
|
+
);
|
@@ -0,0 +1,116 @@
|
|
1
|
+
import jwt
|
2
|
+
from fastapi import FastAPI, HTTPException, Request
|
3
|
+
from fastapi.responses import JSONResponse
|
4
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
5
|
+
|
6
|
+
from planar.logging import get_logger
|
7
|
+
from planar.security.auth_context import Principal, clear_principal, set_principal
|
8
|
+
|
9
|
+
logger = get_logger(__name__)
|
10
|
+
|
11
|
+
BASE_JWKS_URL = "https://auth-api.coplane.com/sso/jwks"
|
12
|
+
EXPECTED_ISSUER = "https://auth-api.coplane.com"
|
13
|
+
|
14
|
+
|
15
|
+
class JWTMiddleware(BaseHTTPMiddleware):
|
16
|
+
def __init__(
|
17
|
+
self,
|
18
|
+
app: FastAPI,
|
19
|
+
client_id: str,
|
20
|
+
org_id: str | None = None,
|
21
|
+
additional_exclusion_paths: list[str] | None = None,
|
22
|
+
):
|
23
|
+
super().__init__(app)
|
24
|
+
self.client_id = client_id
|
25
|
+
self.org_id = org_id
|
26
|
+
self.additional_exclusion_paths = additional_exclusion_paths or []
|
27
|
+
|
28
|
+
def get_signing_key_from_jwt(self, client_id: str, token: str):
|
29
|
+
jwks_url = f"{BASE_JWKS_URL}/{client_id}"
|
30
|
+
jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True)
|
31
|
+
return jwks_client.get_signing_key_from_jwt(token)
|
32
|
+
|
33
|
+
def validate_jwt_token(self, token: str):
|
34
|
+
signing_key = self.get_signing_key_from_jwt(self.client_id, token)
|
35
|
+
|
36
|
+
payload = jwt.decode(
|
37
|
+
token,
|
38
|
+
signing_key,
|
39
|
+
algorithms=["RS256"],
|
40
|
+
issuer=EXPECTED_ISSUER,
|
41
|
+
options={
|
42
|
+
"verify_signature": True,
|
43
|
+
"verify_exp": True,
|
44
|
+
"verify_iss": True,
|
45
|
+
},
|
46
|
+
)
|
47
|
+
|
48
|
+
org_id_from_token = payload.get("org_id")
|
49
|
+
if self.org_id and org_id_from_token != self.org_id:
|
50
|
+
raise HTTPException(
|
51
|
+
status_code=401,
|
52
|
+
detail="Invalid organization",
|
53
|
+
headers={"WWW-Authenticate": "Bearer"},
|
54
|
+
)
|
55
|
+
|
56
|
+
return payload
|
57
|
+
|
58
|
+
async def dispatch(self, request: Request, call_next):
|
59
|
+
if (
|
60
|
+
request.url.path
|
61
|
+
in [
|
62
|
+
"/docs",
|
63
|
+
"/redoc",
|
64
|
+
"/openapi.json",
|
65
|
+
"/planar/v1/health",
|
66
|
+
]
|
67
|
+
or request.url.path in self.additional_exclusion_paths
|
68
|
+
):
|
69
|
+
return await call_next(request)
|
70
|
+
|
71
|
+
principal_token = None
|
72
|
+
try:
|
73
|
+
authorization = request.headers.get("Authorization")
|
74
|
+
if not authorization or not authorization.startswith("Bearer "):
|
75
|
+
return JSONResponse(
|
76
|
+
status_code=401,
|
77
|
+
content={"detail": "Invalid authentication scheme"},
|
78
|
+
headers={"WWW-Authenticate": "Bearer"},
|
79
|
+
)
|
80
|
+
|
81
|
+
token = authorization.replace("Bearer ", "")
|
82
|
+
payload = self.validate_jwt_token(token)
|
83
|
+
|
84
|
+
# Store payload in request state for backward compatibility
|
85
|
+
request.state.user = payload
|
86
|
+
|
87
|
+
# Create and set the principal in context
|
88
|
+
principal = Principal.from_jwt_payload(payload)
|
89
|
+
principal_token = set_principal(principal)
|
90
|
+
|
91
|
+
except ValueError:
|
92
|
+
# Handle invalid JWT payload structure
|
93
|
+
logger.exception("invalid jwt payload structure")
|
94
|
+
return JSONResponse(
|
95
|
+
status_code=401,
|
96
|
+
content={"detail": "Invalid JWT payload structure"},
|
97
|
+
headers={"WWW-Authenticate": "Bearer"},
|
98
|
+
)
|
99
|
+
except HTTPException as e:
|
100
|
+
raise e
|
101
|
+
except Exception:
|
102
|
+
logger.exception("error validating jwt token")
|
103
|
+
return JSONResponse(
|
104
|
+
status_code=401,
|
105
|
+
content={"detail": "Invalid authentication credentials"},
|
106
|
+
headers={"WWW-Authenticate": "Bearer"},
|
107
|
+
)
|
108
|
+
|
109
|
+
try:
|
110
|
+
response = await call_next(request)
|
111
|
+
finally:
|
112
|
+
# Clean up the principal context
|
113
|
+
if principal_token is not None:
|
114
|
+
clear_principal(principal_token)
|
115
|
+
|
116
|
+
return response
|
@@ -0,0 +1,18 @@
|
|
1
|
+
from planar.security.auth_context import Principal, get_current_principal
|
2
|
+
|
3
|
+
SYSTEM_USER = "system"
|
4
|
+
|
5
|
+
|
6
|
+
class SecurityContext:
|
7
|
+
@staticmethod
|
8
|
+
def get_current_user() -> str:
|
9
|
+
"""
|
10
|
+
Get the current authenticated user. Returns 'system' when no principal is found.
|
11
|
+
|
12
|
+
Returns:
|
13
|
+
str: The current user identifier
|
14
|
+
"""
|
15
|
+
principal: Principal | None = get_current_principal()
|
16
|
+
if principal:
|
17
|
+
return principal.sub
|
18
|
+
return SYSTEM_USER
|
@@ -0,0 +1,78 @@
|
|
1
|
+
from planar.security.auth_context import Principal
|
2
|
+
from planar.security.authorization import (
|
3
|
+
CedarEntity,
|
4
|
+
PolicyService,
|
5
|
+
ResourceType,
|
6
|
+
WorkflowAction,
|
7
|
+
get_policy_service,
|
8
|
+
policy_service_context,
|
9
|
+
set_policy_service,
|
10
|
+
)
|
11
|
+
|
12
|
+
|
13
|
+
def test_policy_service_context_variable():
|
14
|
+
"""Test that the authorization service context variable works correctly."""
|
15
|
+
# Initially, no authz service should be set
|
16
|
+
assert get_policy_service() is None
|
17
|
+
|
18
|
+
# Create a mock policy service
|
19
|
+
policy_service = PolicyService()
|
20
|
+
|
21
|
+
# Set the policy service in context
|
22
|
+
set_policy_service(policy_service)
|
23
|
+
assert get_policy_service() is policy_service
|
24
|
+
|
25
|
+
# Reset the context
|
26
|
+
set_policy_service(None)
|
27
|
+
assert get_policy_service() is None
|
28
|
+
|
29
|
+
# Test the context manager
|
30
|
+
async def test_context_manager():
|
31
|
+
async with policy_service_context(policy_service):
|
32
|
+
assert get_policy_service() is policy_service
|
33
|
+
# After exiting the context, it should be None again
|
34
|
+
assert get_policy_service() is None
|
35
|
+
|
36
|
+
# Run the async test
|
37
|
+
import asyncio
|
38
|
+
|
39
|
+
asyncio.run(test_context_manager())
|
40
|
+
|
41
|
+
|
42
|
+
def test_policy_service_with_principal():
|
43
|
+
"""Test that the policy service works with principal resources."""
|
44
|
+
policy_service = PolicyService()
|
45
|
+
|
46
|
+
# Create a mock principal with only required fields
|
47
|
+
principal = Principal(sub="test-user") # type: ignore
|
48
|
+
|
49
|
+
# Create resources
|
50
|
+
principal_resource = CedarEntity.from_principal(principal)
|
51
|
+
workflow_resource = CedarEntity.from_workflow("test_workflow")
|
52
|
+
|
53
|
+
# Test that the service can be used
|
54
|
+
assert principal_resource.resource_type == ResourceType.PRINCIPAL
|
55
|
+
assert workflow_resource.resource_type == ResourceType.WORKFLOW
|
56
|
+
assert principal_resource.resource_attributes["sub"] == "test-user"
|
57
|
+
assert workflow_resource.resource_attributes["function_name"] == "test_workflow"
|
58
|
+
|
59
|
+
assert (
|
60
|
+
policy_service.is_allowed(
|
61
|
+
principal_resource, WorkflowAction.WORKFLOW_RUN, workflow_resource
|
62
|
+
)
|
63
|
+
is True
|
64
|
+
)
|
65
|
+
|
66
|
+
assert (
|
67
|
+
policy_service.is_allowed(
|
68
|
+
principal_resource, WorkflowAction.WORKFLOW_RUN, workflow_resource
|
69
|
+
)
|
70
|
+
is True
|
71
|
+
)
|
72
|
+
|
73
|
+
assert (
|
74
|
+
policy_service.is_allowed(
|
75
|
+
principal_resource, "Workflow::Fail", workflow_resource
|
76
|
+
)
|
77
|
+
is False
|
78
|
+
)
|