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,41 @@
1
+ from cedarpy import Decision, format_policies, is_authorized
2
+
3
+
4
+ def test_cedar_permissions():
5
+ # Define entities
6
+ entities = [
7
+ {
8
+ "uid": {"__entity": {"type": "Principal", "id": "alice"}},
9
+ "attrs": {},
10
+ "parents": [],
11
+ },
12
+ {
13
+ "uid": {"__entity": {"type": "Document", "id": "doc1"}},
14
+ "attrs": {},
15
+ "parents": [],
16
+ },
17
+ ]
18
+
19
+ # Initialize the Cedar policy service
20
+ policy = """
21
+ permit (
22
+ principal,
23
+ action == Action::"Run",
24
+ resource
25
+ );
26
+ """
27
+ formatted_policy = format_policies(policy)
28
+ print(formatted_policy)
29
+
30
+ # Create a simple request
31
+ request = {
32
+ "principal": 'Principal::"alice"',
33
+ "action": 'Action::"Run"',
34
+ "resource": 'Document::"doc1"',
35
+ }
36
+
37
+ # Test the authorization
38
+ result = is_authorized(request, policy, entities)
39
+ assert result.decision == Decision.Allow, (
40
+ "Cedar works and basic permission should be allowed"
41
+ )
@@ -0,0 +1,158 @@
1
+ import pytest
2
+
3
+ from planar.security.auth_context import Principal
4
+ from planar.security.authorization import (
5
+ AgentAction,
6
+ CedarEntity,
7
+ PolicyService,
8
+ RuleAction,
9
+ WorkflowAction,
10
+ )
11
+
12
+
13
+ @pytest.fixture
14
+ def policy_service():
15
+ return PolicyService()
16
+
17
+
18
+ def test_workflow_permissions(policy_service: PolicyService):
19
+ # Create a test principal (user)
20
+ user_principal = Principal(
21
+ sub="user123" # type: ignore
22
+ )
23
+
24
+ # Create test resources
25
+ workflow_resource = CedarEntity.from_workflow("com.example.workflow.ProcessData")
26
+
27
+ # Test Workflow Actions
28
+ assert policy_service.is_allowed(
29
+ CedarEntity.from_principal(user_principal),
30
+ WorkflowAction.WORKFLOW_LIST,
31
+ workflow_resource,
32
+ ), "User should be able to list workflows"
33
+
34
+ assert policy_service.is_allowed(
35
+ CedarEntity.from_principal(user_principal),
36
+ WorkflowAction.WORKFLOW_VIEW_DETAILS,
37
+ workflow_resource,
38
+ ), "User should be able to view workflow details"
39
+
40
+ assert policy_service.is_allowed(
41
+ CedarEntity.from_principal(user_principal),
42
+ WorkflowAction.WORKFLOW_RUN,
43
+ workflow_resource,
44
+ ), "User should be able to run workflow"
45
+
46
+ assert policy_service.is_allowed(
47
+ CedarEntity.from_principal(user_principal),
48
+ WorkflowAction.WORKFLOW_CANCEL,
49
+ workflow_resource,
50
+ ), "User should be able to cancel workflow"
51
+
52
+
53
+ def test_agent_permissions(policy_service: PolicyService):
54
+ user_principal = Principal(
55
+ sub="user123" # type: ignore
56
+ )
57
+
58
+ agent_resource = CedarEntity.from_agent("OcrAgent")
59
+
60
+ # Test Agent Actions
61
+ assert policy_service.is_allowed(
62
+ CedarEntity.from_principal(user_principal),
63
+ AgentAction.AGENT_LIST,
64
+ agent_resource,
65
+ ), "User should be able to list agents"
66
+
67
+ assert policy_service.is_allowed(
68
+ CedarEntity.from_principal(user_principal),
69
+ AgentAction.AGENT_VIEW_DETAILS,
70
+ agent_resource,
71
+ ), "User should be able to view agent details"
72
+
73
+ assert policy_service.is_allowed(
74
+ CedarEntity.from_principal(user_principal),
75
+ AgentAction.AGENT_RUN,
76
+ agent_resource,
77
+ ), "User should be able to run agent"
78
+
79
+ assert policy_service.is_allowed(
80
+ CedarEntity.from_principal(user_principal),
81
+ AgentAction.AGENT_UPDATE,
82
+ agent_resource,
83
+ ), "User should be able to configure agent"
84
+
85
+
86
+ def test_denied_permission(policy_service: PolicyService):
87
+ # Create a test principal (user)
88
+ user_principal = Principal(
89
+ sub="user123" # type: ignore
90
+ )
91
+
92
+ # Create test resource
93
+ workflow_resource = CedarEntity.from_workflow("com.example.workflow.ProcessData")
94
+
95
+ # Test that a non-existent action is denied
96
+ assert not policy_service.is_allowed(
97
+ CedarEntity.from_principal(user_principal),
98
+ "Workflow::Delete", # This action is not defined in our policies
99
+ workflow_resource,
100
+ ), "User should not be able to delete workflows"
101
+
102
+
103
+ def test_deny_edits_to_rules_for_member_role(policy_service: PolicyService):
104
+ member_jwt_data = {
105
+ "org_name": "CoPlane",
106
+ "user_first_name": "Donald",
107
+ "user_last_name": "Knuth",
108
+ "user_email": "don@coplane.com",
109
+ "iss": "https://auth-api.coplane.com",
110
+ "sub": "user_02JYMGMYETXMAVB0GKT868T8V7",
111
+ "sid": "session_01JZ6NJVC1MSR86VZNR54BF9D4",
112
+ "jti": "01JZ8EHD793F6RTY8FC4A3H4E9",
113
+ "org_id": "org_01JY4QP57Y7H4EQ7HT3BGN7TNK",
114
+ "role": "member",
115
+ "permissions": [],
116
+ "feature_flags": [],
117
+ "exp": 1751556901,
118
+ "iat": 1751556601,
119
+ }
120
+
121
+ user_principal = Principal.from_jwt_payload(member_jwt_data)
122
+
123
+ rule_resource = CedarEntity.from_rule("complex_business_rule")
124
+
125
+ assert not policy_service.is_allowed(
126
+ CedarEntity.from_principal(user_principal),
127
+ RuleAction.RULE_UPDATE,
128
+ rule_resource,
129
+ )
130
+
131
+
132
+ def test_allow_edits_to_rules_for_admin_role(policy_service: PolicyService):
133
+ member_jwt_data = {
134
+ "org_name": "CoPlane",
135
+ "user_first_name": "Donald",
136
+ "user_last_name": "Knuth",
137
+ "user_email": "don@coplane.com",
138
+ "iss": "https://auth-api.coplane.com",
139
+ "sub": "user_02JYMGMYETXMAVB0GKT868T8V7",
140
+ "sid": "session_01JZ6NJVC1MSR86VZNR54BF9D4",
141
+ "jti": "01JZ8EHD793F6RTY8FC4A3H4E9",
142
+ "org_id": "org_01JY4QP57Y7H4EQ7HT3BGN7TNK",
143
+ "role": "admin",
144
+ "permissions": [],
145
+ "feature_flags": [],
146
+ "exp": 1751556901,
147
+ "iat": 1751556601,
148
+ }
149
+
150
+ user_principal = Principal.from_jwt_payload(member_jwt_data)
151
+
152
+ rule_resource = CedarEntity.from_rule("complex_business_rule")
153
+
154
+ assert policy_service.is_allowed(
155
+ CedarEntity.from_principal(user_principal),
156
+ RuleAction.RULE_UPDATE,
157
+ rule_resource,
158
+ )
@@ -0,0 +1,179 @@
1
+ import pytest
2
+
3
+ from planar.security.auth_context import (
4
+ Principal,
5
+ clear_principal,
6
+ get_current_principal,
7
+ has_role,
8
+ require_principal,
9
+ set_principal,
10
+ )
11
+
12
+
13
+ def test_principal_from_jwt_payload():
14
+ # Test with minimal required fields
15
+ payload = {"sub": "user123"}
16
+ principal = Principal.from_jwt_payload(payload)
17
+ assert principal.sub == "user123"
18
+ assert principal.extra_claims == {}
19
+
20
+ # Test with all standard fields
21
+ payload = {
22
+ "sub": "user123",
23
+ "iss": "https://auth-api.coplane.com",
24
+ "exp": 1234567890,
25
+ "iat": 1234567890,
26
+ "sid": "session123",
27
+ "jti": "jwt123",
28
+ "org_id": "org123",
29
+ "org_name": "Test Org",
30
+ "user_first_name": "John",
31
+ "user_last_name": "Doe",
32
+ "user_email": "john@example.com",
33
+ "role": "admin",
34
+ "permissions": ["read", "write"],
35
+ }
36
+ principal = Principal.from_jwt_payload(payload)
37
+ assert principal.sub == "user123"
38
+ assert principal.iss == "https://auth-api.coplane.com"
39
+ assert principal.exp == 1234567890
40
+ assert principal.iat == 1234567890
41
+ assert principal.sid == "session123"
42
+ assert principal.jti == "jwt123"
43
+ assert principal.org_id == "org123"
44
+ assert principal.org_name == "Test Org"
45
+ assert principal.user_first_name == "John"
46
+ assert principal.user_last_name == "Doe"
47
+ assert principal.user_email == "john@example.com"
48
+ assert principal.role == "admin"
49
+ assert principal.permissions == ["read", "write"]
50
+ assert principal.extra_claims == {}
51
+
52
+ # Test with extra claims
53
+ payload = {
54
+ "sub": "user123",
55
+ "custom_field": "custom_value",
56
+ "another_field": 123,
57
+ }
58
+ principal = Principal.from_jwt_payload(payload)
59
+ assert principal.sub == "user123"
60
+ assert principal.extra_claims == {
61
+ "custom_field": "custom_value",
62
+ "another_field": 123,
63
+ }
64
+
65
+ # Test with missing required field
66
+ with pytest.raises(ValueError, match="JWT payload must contain 'sub' field"):
67
+ Principal.from_jwt_payload({})
68
+
69
+
70
+ def test_get_current_principal():
71
+ # Test when no principal is set
72
+ assert get_current_principal() is None
73
+
74
+ # Test when principal is set
75
+ principal = Principal(
76
+ sub="user123",
77
+ iss=None,
78
+ exp=None,
79
+ iat=None,
80
+ sid=None,
81
+ jti=None,
82
+ org_id=None,
83
+ org_name=None,
84
+ user_first_name=None,
85
+ user_last_name=None,
86
+ user_email=None,
87
+ role=None,
88
+ permissions=None,
89
+ )
90
+ token = set_principal(principal)
91
+ try:
92
+ assert get_current_principal() == principal
93
+ finally:
94
+ clear_principal(token)
95
+
96
+ # Verify principal is cleared
97
+ assert get_current_principal() is None
98
+
99
+
100
+ def test_has_role():
101
+ # Test when no principal is set
102
+ assert not has_role("admin")
103
+
104
+ # Test when principal has matching role
105
+ principal = Principal(
106
+ sub="user123",
107
+ role="admin",
108
+ iss=None,
109
+ exp=None,
110
+ iat=None,
111
+ sid=None,
112
+ jti=None,
113
+ org_id=None,
114
+ org_name=None,
115
+ user_first_name=None,
116
+ user_last_name=None,
117
+ user_email=None,
118
+ permissions=None,
119
+ )
120
+ token = set_principal(principal)
121
+ try:
122
+ assert has_role("admin")
123
+ assert not has_role("user")
124
+ finally:
125
+ clear_principal(token)
126
+
127
+ # Test when principal has no role
128
+ principal = Principal(
129
+ sub="user123",
130
+ iss=None,
131
+ exp=None,
132
+ iat=None,
133
+ sid=None,
134
+ jti=None,
135
+ org_id=None,
136
+ org_name=None,
137
+ user_first_name=None,
138
+ user_last_name=None,
139
+ user_email=None,
140
+ role=None,
141
+ permissions=None,
142
+ )
143
+ token = set_principal(principal)
144
+ try:
145
+ assert not has_role("admin")
146
+ finally:
147
+ clear_principal(token)
148
+
149
+
150
+ def test_require_principal():
151
+ # Test when no principal is set
152
+ with pytest.raises(RuntimeError, match="No authenticated principal in context"):
153
+ require_principal()
154
+
155
+ # Test when principal is set
156
+ principal = Principal(
157
+ sub="user123",
158
+ iss=None,
159
+ exp=None,
160
+ iat=None,
161
+ sid=None,
162
+ jti=None,
163
+ org_id=None,
164
+ org_name=None,
165
+ user_first_name=None,
166
+ user_last_name=None,
167
+ user_email=None,
168
+ role=None,
169
+ permissions=None,
170
+ )
171
+ token = set_principal(principal)
172
+ try:
173
+ assert require_principal() == principal
174
+ finally:
175
+ clear_principal(token)
176
+
177
+ # Verify principal is cleared
178
+ with pytest.raises(RuntimeError, match="No authenticated principal in context"):
179
+ require_principal()
planar/session.py ADDED
@@ -0,0 +1,40 @@
1
+ from contextlib import asynccontextmanager
2
+ from contextvars import ContextVar
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncEngine
5
+
6
+ from planar.config import PlanarConfig
7
+ from planar.db import PlanarSession
8
+
9
+ session_var: ContextVar[PlanarSession] = ContextVar("session")
10
+ engine_var: ContextVar[AsyncEngine] = ContextVar("engine")
11
+ config_var: ContextVar[PlanarConfig] = ContextVar("config")
12
+
13
+
14
+ def get_engine():
15
+ return engine_var.get()
16
+
17
+
18
+ def get_session():
19
+ return session_var.get()
20
+
21
+
22
+ def get_config():
23
+ return config_var.get()
24
+
25
+
26
+ @asynccontextmanager
27
+ async def session_context(engine: AsyncEngine):
28
+ """Context manager for setting up and tearing down SQLAlchemy session context"""
29
+ # Set the engine in the context
30
+ engine_tok = engine_var.set(engine)
31
+
32
+ async with PlanarSession(engine) as session:
33
+ session_tok = session_var.set(session)
34
+ try:
35
+ yield session
36
+ finally:
37
+ session_var.reset(session_tok)
38
+
39
+ # Reset engine context
40
+ engine_var.reset(engine_tok)
Binary file
Binary file
planar/sse/.hub.py.un~ ADDED
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ SSE_ENDPOINT = "/sse"
@@ -0,0 +1,126 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Planar SSE Example</title>
7
+ <style>
8
+ body {
9
+ font-family: sans-serif;
10
+ max-width: 800px;
11
+ margin: 0 auto;
12
+ padding: 20px;
13
+ }
14
+ #events {
15
+ border: 1px solid #ccc;
16
+ padding: 10px;
17
+ height: 400px;
18
+ overflow-y: auto;
19
+ margin-bottom: 10px;
20
+ white-space: pre-wrap;
21
+ font-family: monospace;
22
+ }
23
+ .event {
24
+ margin-bottom: 10px;
25
+ padding: 5px;
26
+ border-bottom: 1px solid #eee;
27
+ }
28
+ .event-time {
29
+ color: #888;
30
+ font-size: 0.8em;
31
+ }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <h1>Planar SSE</h1>
36
+ <div>
37
+ <div style="margin-bottom: 10px;">
38
+ <label for="endpoint">SSE Endpoint:</label>
39
+ <input type="text" id="endpoint" style="width: 400px;" value="http://127.0.0.1:8000/api/sse?subscribe=workflow-*">
40
+ </div>
41
+ <div>
42
+ <button id="connect">Connect</button>
43
+ <button id="disconnect">Disconnect</button>
44
+ <span id="status">Disconnected</span>
45
+ </div>
46
+ </div>
47
+ <div id="events"></div>
48
+
49
+ <script>
50
+ let eventSource = null;
51
+ const eventsContainer = document.getElementById('events');
52
+ const statusEl = document.getElementById('status');
53
+ const connectBtn = document.getElementById('connect');
54
+ const disconnectBtn = document.getElementById('disconnect');
55
+ const endpointInput = document.getElementById('endpoint');
56
+
57
+ function appendEvent(eventData, eventType = 'message') {
58
+ const eventEl = document.createElement('div');
59
+ eventEl.className = 'event';
60
+
61
+ const time = new Date().toISOString();
62
+ const timeEl = document.createElement('div');
63
+ timeEl.className = 'event-time';
64
+ timeEl.textContent = `[${time}] Type: ${eventType}`;
65
+
66
+ const contentEl = document.createElement('div');
67
+ contentEl.className = 'event-content';
68
+ contentEl.textContent = typeof eventData === 'object' ?
69
+ JSON.stringify(eventData, null, 2) :
70
+ eventData;
71
+
72
+ eventEl.appendChild(timeEl);
73
+ eventEl.appendChild(contentEl);
74
+ eventsContainer.appendChild(eventEl);
75
+ eventsContainer.scrollTop = eventsContainer.scrollHeight;
76
+ }
77
+
78
+ function connect() {
79
+ if (eventSource) {
80
+ eventSource.close();
81
+ }
82
+
83
+ const url = endpointInput.value.trim();
84
+ if (!url) {
85
+ appendEvent('Please enter a valid endpoint URL', 'error');
86
+ return;
87
+ }
88
+
89
+ eventSource = new EventSource(url);
90
+
91
+ eventSource.onopen = () => {
92
+ statusEl.textContent = 'Connected to ' + url;
93
+ appendEvent('Connection established');
94
+ };
95
+
96
+ eventSource.onmessage = (event) => {
97
+ try {
98
+ const data = JSON.parse(event.data);
99
+ appendEvent(data.payload, data.name);
100
+ } catch (e) {
101
+ appendEvent(event.data);
102
+ }
103
+ };
104
+
105
+ eventSource.onerror = (error) => {
106
+ statusEl.textContent = 'Error/Disconnected';
107
+ appendEvent(`Connection error: ${error.type}`, 'error');
108
+ eventSource.close();
109
+ eventSource = null;
110
+ };
111
+ }
112
+
113
+ function disconnect() {
114
+ if (eventSource) {
115
+ eventSource.close();
116
+ eventSource = null;
117
+ statusEl.textContent = 'Disconnected';
118
+ appendEvent('Connection closed');
119
+ }
120
+ }
121
+
122
+ connectBtn.addEventListener('click', connect);
123
+ disconnectBtn.addEventListener('click', disconnect);
124
+ </script>
125
+ </body>
126
+ </html>