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.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. 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
+ )