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,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
|
planar/sse/.model.py.un~
ADDED
Binary file
|
planar/sse/.proxy.py.un~
ADDED
Binary file
|
planar/sse/constants.py
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
SSE_ENDPOINT = "/sse"
|
planar/sse/example.html
ADDED
@@ -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>
|