django-clerk-users 0.0.1__py3-none-any.whl → 0.1.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 (47) hide show
  1. django_clerk_users/__init__.py +78 -7
  2. django_clerk_users/apps.py +20 -0
  3. django_clerk_users/authentication/__init__.py +24 -0
  4. django_clerk_users/authentication/backends.py +89 -0
  5. django_clerk_users/authentication/drf.py +111 -0
  6. django_clerk_users/authentication/utils.py +171 -0
  7. django_clerk_users/caching.py +161 -0
  8. django_clerk_users/checks.py +127 -0
  9. django_clerk_users/client.py +32 -0
  10. django_clerk_users/decorators.py +181 -0
  11. django_clerk_users/exceptions.py +51 -0
  12. django_clerk_users/management/__init__.py +0 -0
  13. django_clerk_users/management/commands/__init__.py +0 -0
  14. django_clerk_users/management/commands/migrate_users_to_clerk.py +223 -0
  15. django_clerk_users/management/commands/sync_clerk_organizations.py +191 -0
  16. django_clerk_users/management/commands/sync_clerk_users.py +114 -0
  17. django_clerk_users/managers.py +120 -0
  18. django_clerk_users/middleware/__init__.py +9 -0
  19. django_clerk_users/middleware/auth.py +230 -0
  20. django_clerk_users/migrations/0001_initial.py +174 -0
  21. django_clerk_users/migrations/0002_make_clerk_id_nullable.py +24 -0
  22. django_clerk_users/migrations/__init__.py +0 -0
  23. django_clerk_users/models.py +180 -0
  24. django_clerk_users/organizations/__init__.py +8 -0
  25. django_clerk_users/organizations/admin.py +81 -0
  26. django_clerk_users/organizations/apps.py +8 -0
  27. django_clerk_users/organizations/middleware.py +130 -0
  28. django_clerk_users/organizations/migrations/0001_initial.py +349 -0
  29. django_clerk_users/organizations/migrations/__init__.py +0 -0
  30. django_clerk_users/organizations/models.py +314 -0
  31. django_clerk_users/organizations/webhooks.py +417 -0
  32. django_clerk_users/settings.py +37 -0
  33. django_clerk_users/testing.py +381 -0
  34. django_clerk_users/utils.py +210 -0
  35. django_clerk_users/webhooks/__init__.py +26 -0
  36. django_clerk_users/webhooks/handlers.py +346 -0
  37. django_clerk_users/webhooks/security.py +108 -0
  38. django_clerk_users/webhooks/signals.py +42 -0
  39. django_clerk_users/webhooks/views.py +76 -0
  40. django_clerk_users-0.1.0.dist-info/METADATA +311 -0
  41. django_clerk_users-0.1.0.dist-info/RECORD +43 -0
  42. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/WHEEL +1 -2
  43. django_clerk_users/main.py +0 -2
  44. django_clerk_users-0.0.1.dist-info/METADATA +0 -24
  45. django_clerk_users-0.0.1.dist-info/RECORD +0 -7
  46. django_clerk_users-0.0.1.dist-info/top_level.txt +0 -1
  47. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,417 @@
1
+ """
2
+ Webhook handlers for organization events.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from django.db import transaction
11
+
12
+ from django_clerk_users.caching import invalidate_organization_cache
13
+ from django_clerk_users.webhooks.handlers import parse_clerk_timestamp
14
+ from django_clerk_users.webhooks.signals import (
15
+ clerk_invitation_accepted,
16
+ clerk_invitation_created,
17
+ clerk_invitation_revoked,
18
+ clerk_membership_created,
19
+ clerk_membership_deleted,
20
+ clerk_membership_updated,
21
+ clerk_organization_created,
22
+ clerk_organization_deleted,
23
+ clerk_organization_updated,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def update_or_create_organization(org_id: str) -> tuple:
30
+ """
31
+ Update or create an Organization from Clerk data.
32
+
33
+ Args:
34
+ org_id: The Clerk organization ID.
35
+
36
+ Returns:
37
+ A tuple of (organization, created).
38
+ """
39
+ from django_clerk_users.client import get_clerk_client
40
+ from django_clerk_users.organizations.models import Organization
41
+
42
+ clerk = get_clerk_client()
43
+ clerk_org = clerk.organizations.get(organization_id=org_id)
44
+
45
+ if not clerk_org:
46
+ logger.error(f"Organization not found in Clerk: {org_id}")
47
+ return None, False
48
+
49
+ org_data = {
50
+ "name": getattr(clerk_org, "name", ""),
51
+ "slug": getattr(clerk_org, "slug", ""),
52
+ "image_url": getattr(clerk_org, "image_url", "") or "",
53
+ "public_metadata": getattr(clerk_org, "public_metadata", {}) or {},
54
+ "private_metadata": getattr(clerk_org, "private_metadata", {}) or {},
55
+ "members_count": getattr(clerk_org, "members_count", 0) or 0,
56
+ "pending_invitations_count": getattr(clerk_org, "pending_invitations_count", 0) or 0,
57
+ "max_allowed_memberships": getattr(clerk_org, "max_allowed_memberships", 0) or 0,
58
+ }
59
+
60
+ created_at = parse_clerk_timestamp(getattr(clerk_org, "created_at", None))
61
+ if created_at:
62
+ org_data["created_at"] = created_at
63
+
64
+ organization, created = Organization.objects.update_or_create(
65
+ clerk_id=org_id,
66
+ defaults=org_data,
67
+ )
68
+
69
+ return organization, created
70
+
71
+
72
+ @transaction.atomic
73
+ def handle_organization_created(data: dict[str, Any]) -> bool:
74
+ """Handle organization.created webhook event."""
75
+ from django_clerk_users.organizations.models import Organization
76
+
77
+ org_id = data.get("id")
78
+ if not org_id:
79
+ logger.error("organization.created webhook missing organization ID")
80
+ return False
81
+
82
+ try:
83
+ organization, created = update_or_create_organization(org_id)
84
+ if organization:
85
+ clerk_organization_created.send(
86
+ sender=Organization,
87
+ organization=organization,
88
+ clerk_data=data,
89
+ )
90
+ logger.info(f"Organization created: {organization.name}")
91
+ return True
92
+ return False
93
+ except Exception as e:
94
+ logger.error(f"Failed to handle organization.created: {e}")
95
+ return False
96
+
97
+
98
+ @transaction.atomic
99
+ def handle_organization_updated(data: dict[str, Any]) -> bool:
100
+ """Handle organization.updated webhook event."""
101
+ from django_clerk_users.organizations.models import Organization
102
+
103
+ org_id = data.get("id")
104
+ if not org_id:
105
+ logger.error("organization.updated webhook missing organization ID")
106
+ return False
107
+
108
+ try:
109
+ invalidate_organization_cache(org_id)
110
+ organization, created = update_or_create_organization(org_id)
111
+ if organization:
112
+ clerk_organization_updated.send(
113
+ sender=Organization,
114
+ organization=organization,
115
+ clerk_data=data,
116
+ )
117
+ logger.info(f"Organization updated: {organization.name}")
118
+ return True
119
+ return False
120
+ except Exception as e:
121
+ logger.error(f"Failed to handle organization.updated: {e}")
122
+ return False
123
+
124
+
125
+ @transaction.atomic
126
+ def handle_organization_deleted(data: dict[str, Any]) -> bool:
127
+ """Handle organization.deleted webhook event."""
128
+ from django_clerk_users.organizations.models import Organization
129
+
130
+ org_id = data.get("id")
131
+ if not org_id:
132
+ logger.error("organization.deleted webhook missing organization ID")
133
+ return False
134
+
135
+ try:
136
+ invalidate_organization_cache(org_id)
137
+ organization = Organization.objects.filter(clerk_id=org_id).first()
138
+ if organization:
139
+ organization.is_active = False
140
+ organization.save(update_fields=["is_active", "updated_at"])
141
+ clerk_organization_deleted.send(
142
+ sender=Organization,
143
+ organization=organization,
144
+ clerk_data=data,
145
+ )
146
+ logger.info(f"Organization deleted: {organization.name}")
147
+ return True
148
+ except Exception as e:
149
+ logger.error(f"Failed to handle organization.deleted: {e}")
150
+ return False
151
+
152
+
153
+ @transaction.atomic
154
+ def handle_membership_created(data: dict[str, Any]) -> bool:
155
+ """Handle organizationMembership.created webhook event."""
156
+ from django.contrib.auth import get_user_model
157
+
158
+ from django_clerk_users.organizations.models import Organization, OrganizationMember
159
+
160
+ User = get_user_model()
161
+
162
+ membership_id = data.get("id")
163
+ org_data = data.get("organization", {})
164
+ user_data = data.get("public_user_data", {})
165
+
166
+ org_id = org_data.get("id")
167
+ user_id = user_data.get("user_id")
168
+
169
+ if not all([membership_id, org_id, user_id]):
170
+ logger.error("organizationMembership.created webhook missing required fields")
171
+ return False
172
+
173
+ try:
174
+ organization = Organization.objects.filter(clerk_id=org_id).first()
175
+ if not organization:
176
+ organization, _ = update_or_create_organization(org_id)
177
+
178
+ user = User.objects.filter(clerk_id=user_id).first()
179
+ if not user:
180
+ from django_clerk_users.utils import update_or_create_clerk_user
181
+ user, _ = update_or_create_clerk_user(user_id)
182
+
183
+ membership, created = OrganizationMember.objects.update_or_create(
184
+ clerk_membership_id=membership_id,
185
+ defaults={
186
+ "organization": organization,
187
+ "user": user,
188
+ "role": data.get("role", "member"),
189
+ "public_metadata": data.get("public_metadata", {}),
190
+ "private_metadata": data.get("private_metadata", {}),
191
+ },
192
+ )
193
+
194
+ joined_at = parse_clerk_timestamp(data.get("created_at"))
195
+ if joined_at:
196
+ membership.joined_at = joined_at
197
+ membership.save(update_fields=["joined_at"])
198
+
199
+ clerk_membership_created.send(
200
+ sender=OrganizationMember,
201
+ membership=membership,
202
+ clerk_data=data,
203
+ )
204
+ logger.info(f"Membership created: {user.email} in {organization.name}")
205
+ return True
206
+
207
+ except Exception as e:
208
+ logger.error(f"Failed to handle organizationMembership.created: {e}")
209
+ return False
210
+
211
+
212
+ @transaction.atomic
213
+ def handle_membership_updated(data: dict[str, Any]) -> bool:
214
+ """Handle organizationMembership.updated webhook event."""
215
+ from django_clerk_users.organizations.models import OrganizationMember
216
+
217
+ membership_id = data.get("id")
218
+ if not membership_id:
219
+ logger.error("organizationMembership.updated webhook missing membership ID")
220
+ return False
221
+
222
+ try:
223
+ membership = OrganizationMember.objects.filter(
224
+ clerk_membership_id=membership_id
225
+ ).first()
226
+ if membership:
227
+ membership.role = data.get("role", membership.role)
228
+ membership.public_metadata = data.get("public_metadata", membership.public_metadata)
229
+ membership.private_metadata = data.get("private_metadata", membership.private_metadata)
230
+ membership.save()
231
+
232
+ clerk_membership_updated.send(
233
+ sender=OrganizationMember,
234
+ membership=membership,
235
+ clerk_data=data,
236
+ )
237
+ logger.info(f"Membership updated: {membership}")
238
+ return True
239
+
240
+ except Exception as e:
241
+ logger.error(f"Failed to handle organizationMembership.updated: {e}")
242
+ return False
243
+
244
+
245
+ @transaction.atomic
246
+ def handle_membership_deleted(data: dict[str, Any]) -> bool:
247
+ """Handle organizationMembership.deleted webhook event."""
248
+ from django_clerk_users.organizations.models import OrganizationMember
249
+
250
+ membership_id = data.get("id")
251
+ if not membership_id:
252
+ logger.error("organizationMembership.deleted webhook missing membership ID")
253
+ return False
254
+
255
+ try:
256
+ membership = OrganizationMember.objects.filter(
257
+ clerk_membership_id=membership_id
258
+ ).first()
259
+ if membership:
260
+ clerk_membership_deleted.send(
261
+ sender=OrganizationMember,
262
+ membership=membership,
263
+ clerk_data=data,
264
+ )
265
+ membership.delete()
266
+ logger.info(f"Membership deleted: {membership_id}")
267
+ return True
268
+
269
+ except Exception as e:
270
+ logger.error(f"Failed to handle organizationMembership.deleted: {e}")
271
+ return False
272
+
273
+
274
+ @transaction.atomic
275
+ def handle_invitation_created(data: dict[str, Any]) -> bool:
276
+ """Handle organizationInvitation.created webhook event."""
277
+ from django.contrib.auth import get_user_model
278
+
279
+ from django_clerk_users.organizations.models import Organization, OrganizationInvitation
280
+
281
+ User = get_user_model()
282
+
283
+ invitation_id = data.get("id")
284
+ org_id = data.get("organization_id")
285
+ email = data.get("email_address")
286
+
287
+ if not all([invitation_id, org_id, email]):
288
+ logger.error("organizationInvitation.created webhook missing required fields")
289
+ return False
290
+
291
+ try:
292
+ organization = Organization.objects.filter(clerk_id=org_id).first()
293
+ if not organization:
294
+ organization, _ = update_or_create_organization(org_id)
295
+
296
+ inviter = None
297
+ inviter_id = data.get("inviter_user_id")
298
+ if inviter_id:
299
+ inviter = User.objects.filter(clerk_id=inviter_id).first()
300
+
301
+ invitation, created = OrganizationInvitation.objects.update_or_create(
302
+ clerk_invitation_id=invitation_id,
303
+ defaults={
304
+ "organization": organization,
305
+ "inviter": inviter,
306
+ "email_address": email,
307
+ "role": data.get("role", "member"),
308
+ "status": OrganizationInvitation.Status.PENDING,
309
+ "public_metadata": data.get("public_metadata", {}),
310
+ "private_metadata": data.get("private_metadata", {}),
311
+ },
312
+ )
313
+
314
+ clerk_invitation_created.send(
315
+ sender=OrganizationInvitation,
316
+ invitation=invitation,
317
+ clerk_data=data,
318
+ )
319
+ logger.info(f"Invitation created: {email} to {organization.name}")
320
+ return True
321
+
322
+ except Exception as e:
323
+ logger.error(f"Failed to handle organizationInvitation.created: {e}")
324
+ return False
325
+
326
+
327
+ @transaction.atomic
328
+ def handle_invitation_accepted(data: dict[str, Any]) -> bool:
329
+ """Handle organizationInvitation.accepted webhook event."""
330
+ from django_clerk_users.organizations.models import OrganizationInvitation
331
+
332
+ invitation_id = data.get("id")
333
+ if not invitation_id:
334
+ logger.error("organizationInvitation.accepted webhook missing invitation ID")
335
+ return False
336
+
337
+ try:
338
+ invitation = OrganizationInvitation.objects.filter(
339
+ clerk_invitation_id=invitation_id
340
+ ).first()
341
+ if invitation:
342
+ invitation.status = OrganizationInvitation.Status.ACCEPTED
343
+ invitation.save(update_fields=["status", "updated_at"])
344
+
345
+ clerk_invitation_accepted.send(
346
+ sender=OrganizationInvitation,
347
+ invitation=invitation,
348
+ clerk_data=data,
349
+ )
350
+ logger.info(f"Invitation accepted: {invitation.email_address}")
351
+ return True
352
+
353
+ except Exception as e:
354
+ logger.error(f"Failed to handle organizationInvitation.accepted: {e}")
355
+ return False
356
+
357
+
358
+ @transaction.atomic
359
+ def handle_invitation_revoked(data: dict[str, Any]) -> bool:
360
+ """Handle organizationInvitation.revoked webhook event."""
361
+ from django_clerk_users.organizations.models import OrganizationInvitation
362
+
363
+ invitation_id = data.get("id")
364
+ if not invitation_id:
365
+ logger.error("organizationInvitation.revoked webhook missing invitation ID")
366
+ return False
367
+
368
+ try:
369
+ invitation = OrganizationInvitation.objects.filter(
370
+ clerk_invitation_id=invitation_id
371
+ ).first()
372
+ if invitation:
373
+ invitation.status = OrganizationInvitation.Status.REVOKED
374
+ invitation.save(update_fields=["status", "updated_at"])
375
+
376
+ clerk_invitation_revoked.send(
377
+ sender=OrganizationInvitation,
378
+ invitation=invitation,
379
+ clerk_data=data,
380
+ )
381
+ logger.info(f"Invitation revoked: {invitation.email_address}")
382
+ return True
383
+
384
+ except Exception as e:
385
+ logger.error(f"Failed to handle organizationInvitation.revoked: {e}")
386
+ return False
387
+
388
+
389
+ def process_organization_event(event_type: str, data: dict[str, Any]) -> bool:
390
+ """
391
+ Process an organization-related webhook event.
392
+
393
+ Args:
394
+ event_type: The Clerk event type.
395
+ data: The event data.
396
+
397
+ Returns:
398
+ True if handled successfully, False otherwise.
399
+ """
400
+ handlers = {
401
+ "organization.created": handle_organization_created,
402
+ "organization.updated": handle_organization_updated,
403
+ "organization.deleted": handle_organization_deleted,
404
+ "organizationMembership.created": handle_membership_created,
405
+ "organizationMembership.updated": handle_membership_updated,
406
+ "organizationMembership.deleted": handle_membership_deleted,
407
+ "organizationInvitation.created": handle_invitation_created,
408
+ "organizationInvitation.accepted": handle_invitation_accepted,
409
+ "organizationInvitation.revoked": handle_invitation_revoked,
410
+ }
411
+
412
+ handler = handlers.get(event_type)
413
+ if handler:
414
+ return handler(data)
415
+
416
+ logger.debug(f"Unhandled organization event type: {event_type}")
417
+ return True
@@ -0,0 +1,37 @@
1
+ """
2
+ Settings for django-clerk-users package.
3
+
4
+ All settings are prefixed with CLERK_ and can be set in Django's settings.py.
5
+ """
6
+
7
+ from django.conf import settings
8
+
9
+ # Required settings
10
+ CLERK_SECRET_KEY: str | None = getattr(settings, "CLERK_SECRET_KEY", None)
11
+ CLERK_WEBHOOK_SIGNING_KEY: str | None = getattr(
12
+ settings, "CLERK_WEBHOOK_SIGNING_KEY", None
13
+ )
14
+
15
+ # Authorized frontend hosts for JWT validation (authorized_parties)
16
+ CLERK_FRONTEND_HOSTS: list[str] = getattr(settings, "CLERK_FRONTEND_HOSTS", [])
17
+
18
+ # Alias for CLERK_FRONTEND_HOSTS for consistency with existing implementations
19
+ CLERK_AUTH_PARTIES: list[str] = getattr(
20
+ settings, "CLERK_AUTH_PARTIES", CLERK_FRONTEND_HOSTS
21
+ )
22
+
23
+ # Session revalidation interval in seconds (default: 5 minutes)
24
+ CLERK_SESSION_REVALIDATION_SECONDS: int = getattr(
25
+ settings, "CLERK_SESSION_REVALIDATION_SECONDS", 300
26
+ )
27
+
28
+ # Cache timeout for JWT payloads and user lookups (default: 5 minutes)
29
+ CLERK_CACHE_TIMEOUT: int = getattr(settings, "CLERK_CACHE_TIMEOUT", 300)
30
+
31
+ # Cache timeout for organization lookups (default: 15 minutes)
32
+ CLERK_ORG_CACHE_TIMEOUT: int = getattr(settings, "CLERK_ORG_CACHE_TIMEOUT", 900)
33
+
34
+ # Webhook deduplication cache timeout (default: 45 seconds)
35
+ CLERK_WEBHOOK_DEDUP_TIMEOUT: int = getattr(
36
+ settings, "CLERK_WEBHOOK_DEDUP_TIMEOUT", 45
37
+ )