django-clerk-users 0.0.1__py3-none-any.whl → 0.0.2__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 (44) 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 +121 -0
  18. django_clerk_users/middleware/__init__.py +9 -0
  19. django_clerk_users/middleware/auth.py +201 -0
  20. django_clerk_users/migrations/0001_initial.py +174 -0
  21. django_clerk_users/migrations/__init__.py +0 -0
  22. django_clerk_users/models.py +174 -0
  23. django_clerk_users/organizations/__init__.py +8 -0
  24. django_clerk_users/organizations/admin.py +81 -0
  25. django_clerk_users/organizations/apps.py +8 -0
  26. django_clerk_users/organizations/middleware.py +130 -0
  27. django_clerk_users/organizations/models.py +316 -0
  28. django_clerk_users/organizations/webhooks.py +417 -0
  29. django_clerk_users/settings.py +37 -0
  30. django_clerk_users/testing.py +381 -0
  31. django_clerk_users/utils.py +210 -0
  32. django_clerk_users/webhooks/__init__.py +26 -0
  33. django_clerk_users/webhooks/handlers.py +346 -0
  34. django_clerk_users/webhooks/security.py +108 -0
  35. django_clerk_users/webhooks/signals.py +42 -0
  36. django_clerk_users/webhooks/views.py +76 -0
  37. django_clerk_users-0.0.2.dist-info/METADATA +228 -0
  38. django_clerk_users-0.0.2.dist-info/RECORD +41 -0
  39. django_clerk_users/main.py +0 -2
  40. django_clerk_users-0.0.1.dist-info/METADATA +0 -24
  41. django_clerk_users-0.0.1.dist-info/RECORD +0 -7
  42. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/WHEEL +0 -0
  43. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/licenses/LICENSE +0 -0
  44. {django_clerk_users-0.0.1.dist-info → django_clerk_users-0.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,174 @@
1
+ # Generated by Django 5.2.8 on 2026-01-13 00:44
2
+
3
+ import django.utils.timezone
4
+ import uuid
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ("auth", "0012_alter_user_first_name_max_length"),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="ClerkUser",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigAutoField(
22
+ auto_created=True,
23
+ primary_key=True,
24
+ serialize=False,
25
+ verbose_name="ID",
26
+ ),
27
+ ),
28
+ ("password", models.CharField(max_length=128, verbose_name="password")),
29
+ (
30
+ "is_superuser",
31
+ models.BooleanField(
32
+ default=False,
33
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
34
+ verbose_name="superuser status",
35
+ ),
36
+ ),
37
+ (
38
+ "uid",
39
+ models.UUIDField(
40
+ db_index=True,
41
+ default=uuid.uuid4,
42
+ editable=False,
43
+ help_text="Public unique identifier for the user.",
44
+ ),
45
+ ),
46
+ (
47
+ "clerk_id",
48
+ models.CharField(
49
+ db_index=True,
50
+ help_text="Unique identifier from Clerk.",
51
+ max_length=255,
52
+ unique=True,
53
+ ),
54
+ ),
55
+ (
56
+ "email",
57
+ models.EmailField(
58
+ db_index=True,
59
+ help_text="User's email address.",
60
+ max_length=254,
61
+ unique=True,
62
+ ),
63
+ ),
64
+ (
65
+ "first_name",
66
+ models.CharField(
67
+ blank=True,
68
+ default="",
69
+ help_text="User's first name.",
70
+ max_length=255,
71
+ ),
72
+ ),
73
+ (
74
+ "last_name",
75
+ models.CharField(
76
+ blank=True,
77
+ default="",
78
+ help_text="User's last name.",
79
+ max_length=255,
80
+ ),
81
+ ),
82
+ (
83
+ "image_url",
84
+ models.URLField(
85
+ blank=True,
86
+ default="",
87
+ help_text="URL to user's profile image from Clerk.",
88
+ max_length=500,
89
+ ),
90
+ ),
91
+ (
92
+ "is_active",
93
+ models.BooleanField(
94
+ default=True, help_text="Whether the user account is active."
95
+ ),
96
+ ),
97
+ (
98
+ "is_staff",
99
+ models.BooleanField(
100
+ default=False,
101
+ help_text="Whether the user can access the admin site.",
102
+ ),
103
+ ),
104
+ (
105
+ "created_at",
106
+ models.DateTimeField(
107
+ default=django.utils.timezone.now,
108
+ help_text="When the user was created.",
109
+ ),
110
+ ),
111
+ (
112
+ "updated_at",
113
+ models.DateTimeField(
114
+ auto_now=True, help_text="When the user was last updated."
115
+ ),
116
+ ),
117
+ (
118
+ "last_login",
119
+ models.DateTimeField(
120
+ blank=True,
121
+ help_text="Last login timestamp (managed by Clerk).",
122
+ null=True,
123
+ ),
124
+ ),
125
+ (
126
+ "last_logout",
127
+ models.DateTimeField(
128
+ blank=True,
129
+ help_text="Last logout timestamp (managed by Clerk).",
130
+ null=True,
131
+ ),
132
+ ),
133
+ (
134
+ "groups",
135
+ models.ManyToManyField(
136
+ blank=True,
137
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
138
+ related_name="user_set",
139
+ related_query_name="user",
140
+ to="auth.group",
141
+ verbose_name="groups",
142
+ ),
143
+ ),
144
+ (
145
+ "user_permissions",
146
+ models.ManyToManyField(
147
+ blank=True,
148
+ help_text="Specific permissions for this user.",
149
+ related_name="user_set",
150
+ related_query_name="user",
151
+ to="auth.permission",
152
+ verbose_name="user permissions",
153
+ ),
154
+ ),
155
+ ],
156
+ options={
157
+ "verbose_name": "Clerk User",
158
+ "verbose_name_plural": "Clerk Users",
159
+ "ordering": ["-created_at"],
160
+ "abstract": False,
161
+ "swappable": "AUTH_USER_MODEL",
162
+ "indexes": [
163
+ models.Index(
164
+ fields=["clerk_id"], name="django_cler_clerk_i_d591b6_idx"
165
+ ),
166
+ models.Index(fields=["email"], name="django_cler_email_f1e649_idx"),
167
+ models.Index(fields=["uid"], name="django_cler_uid_9f6a95_idx"),
168
+ models.Index(
169
+ fields=["is_active"], name="django_cler_is_acti_ceff9c_idx"
170
+ ),
171
+ ],
172
+ },
173
+ ),
174
+ ]
File without changes
@@ -0,0 +1,174 @@
1
+ """
2
+ User models for django-clerk-users.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from typing import Any
9
+
10
+ from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
11
+ from django.db import models
12
+ from django.utils import timezone
13
+
14
+ from django_clerk_users.managers import ClerkUserManager
15
+
16
+
17
+ class AbstractClerkUser(AbstractBaseUser, PermissionsMixin):
18
+ """
19
+ Abstract base class for Clerk-authenticated users.
20
+
21
+ Extend this class to create a custom user model with additional fields
22
+ while maintaining Clerk integration.
23
+
24
+ Example:
25
+ class CustomUser(AbstractClerkUser):
26
+ company = models.CharField(max_length=255, blank=True)
27
+ phone = models.CharField(max_length=20, blank=True)
28
+
29
+ class Meta(AbstractClerkUser.Meta):
30
+ swappable = "AUTH_USER_MODEL"
31
+ """
32
+
33
+ # Public identifier (use this in URLs and APIs instead of pk)
34
+ uid = models.UUIDField(
35
+ default=uuid.uuid4,
36
+ editable=False,
37
+ db_index=True,
38
+ help_text="Public unique identifier for the user.",
39
+ )
40
+
41
+ # Clerk-specific fields
42
+ clerk_id = models.CharField(
43
+ max_length=255,
44
+ unique=True,
45
+ db_index=True,
46
+ help_text="Unique identifier from Clerk.",
47
+ )
48
+
49
+ # Standard user fields
50
+ email = models.EmailField(
51
+ unique=True,
52
+ db_index=True,
53
+ help_text="User's email address.",
54
+ )
55
+ first_name = models.CharField(
56
+ max_length=255,
57
+ blank=True,
58
+ default="",
59
+ help_text="User's first name.",
60
+ )
61
+ last_name = models.CharField(
62
+ max_length=255,
63
+ blank=True,
64
+ default="",
65
+ help_text="User's last name.",
66
+ )
67
+ image_url = models.URLField(
68
+ max_length=500,
69
+ blank=True,
70
+ default="",
71
+ help_text="URL to user's profile image from Clerk.",
72
+ )
73
+
74
+ # Status fields
75
+ is_active = models.BooleanField(
76
+ default=True,
77
+ help_text="Whether the user account is active.",
78
+ )
79
+ is_staff = models.BooleanField(
80
+ default=False,
81
+ help_text="Whether the user can access the admin site.",
82
+ )
83
+
84
+ # Timestamps
85
+ created_at = models.DateTimeField(
86
+ default=timezone.now,
87
+ help_text="When the user was created.",
88
+ )
89
+ updated_at = models.DateTimeField(
90
+ auto_now=True,
91
+ help_text="When the user was last updated.",
92
+ )
93
+ last_login = models.DateTimeField(
94
+ null=True,
95
+ blank=True,
96
+ help_text="Last login timestamp (managed by Clerk).",
97
+ )
98
+ last_logout = models.DateTimeField(
99
+ null=True,
100
+ blank=True,
101
+ help_text="Last logout timestamp (managed by Clerk).",
102
+ )
103
+
104
+ objects = ClerkUserManager()
105
+
106
+ USERNAME_FIELD = "email"
107
+ REQUIRED_FIELDS = ["clerk_id"]
108
+
109
+ class Meta:
110
+ abstract = True
111
+ ordering = ["-created_at"]
112
+ indexes = [
113
+ models.Index(fields=["clerk_id"]),
114
+ models.Index(fields=["email"]),
115
+ models.Index(fields=["uid"]),
116
+ models.Index(fields=["is_active"]),
117
+ ]
118
+
119
+ def __str__(self) -> str:
120
+ return self.email
121
+
122
+ @property
123
+ def public_id(self) -> str:
124
+ """Return the public UUID as a string for API responses."""
125
+ return str(self.uid)
126
+
127
+ @property
128
+ def full_name(self) -> str:
129
+ """Return the user's full name."""
130
+ return f"{self.first_name} {self.last_name}".strip()
131
+
132
+ def get_full_name(self) -> str:
133
+ """Return the user's full name (Django compatibility)."""
134
+ return self.full_name
135
+
136
+ def get_short_name(self) -> str:
137
+ """Return the user's first name (Django compatibility)."""
138
+ return self.first_name or self.email.split("@")[0]
139
+
140
+ def has_perm(self, perm: str, obj: Any = None) -> bool:
141
+ """
142
+ Return True if the user has the specified permission.
143
+
144
+ For Clerk users, permissions are typically managed through
145
+ Clerk's organization roles or custom metadata.
146
+ Superusers have all permissions.
147
+ """
148
+ if self.is_superuser:
149
+ return True
150
+ return super().has_perm(perm, obj)
151
+
152
+ def has_module_perms(self, app_label: str) -> bool:
153
+ """
154
+ Return True if the user has any permissions in the given app.
155
+
156
+ Superusers have all permissions.
157
+ """
158
+ if self.is_superuser:
159
+ return True
160
+ return super().has_module_perms(app_label)
161
+
162
+
163
+ class ClerkUser(AbstractClerkUser):
164
+ """
165
+ Concrete user model for Clerk authentication.
166
+
167
+ Use this model directly by setting AUTH_USER_MODEL = "django_clerk_users.ClerkUser"
168
+ in your Django settings, or extend AbstractClerkUser for custom fields.
169
+ """
170
+
171
+ class Meta(AbstractClerkUser.Meta):
172
+ swappable = "AUTH_USER_MODEL"
173
+ verbose_name = "Clerk User"
174
+ verbose_name_plural = "Clerk Users"
@@ -0,0 +1,8 @@
1
+ """
2
+ Organizations sub-app for django-clerk-users.
3
+
4
+ This is an optional sub-app that provides organization support.
5
+ To use it, add 'django_clerk_users.organizations' to INSTALLED_APPS.
6
+ """
7
+
8
+ default_app_config = "django_clerk_users.organizations.apps.OrganizationsConfig"
@@ -0,0 +1,81 @@
1
+ """
2
+ Django admin configuration for organization models.
3
+ """
4
+
5
+ from django.contrib import admin
6
+
7
+ from django_clerk_users.organizations.models import (
8
+ Organization,
9
+ OrganizationInvitation,
10
+ OrganizationMember,
11
+ )
12
+
13
+
14
+ @admin.register(Organization)
15
+ class OrganizationAdmin(admin.ModelAdmin):
16
+ list_display = [
17
+ "name",
18
+ "slug",
19
+ "clerk_id",
20
+ "members_count",
21
+ "is_active",
22
+ "created_at",
23
+ ]
24
+ list_filter = ["is_active", "created_at"]
25
+ search_fields = ["name", "slug", "clerk_id"]
26
+ readonly_fields = [
27
+ "uid",
28
+ "clerk_id",
29
+ "created_at",
30
+ "updated_at",
31
+ "members_count",
32
+ "pending_invitations_count",
33
+ ]
34
+ ordering = ["-created_at"]
35
+
36
+
37
+ @admin.register(OrganizationMember)
38
+ class OrganizationMemberAdmin(admin.ModelAdmin):
39
+ list_display = [
40
+ "user",
41
+ "organization",
42
+ "role",
43
+ "joined_at",
44
+ ]
45
+ list_filter = ["role", "joined_at"]
46
+ search_fields = [
47
+ "user__email",
48
+ "organization__name",
49
+ "clerk_membership_id",
50
+ ]
51
+ readonly_fields = [
52
+ "clerk_membership_id",
53
+ "joined_at",
54
+ "updated_at",
55
+ ]
56
+ raw_id_fields = ["user", "organization"]
57
+ ordering = ["-joined_at"]
58
+
59
+
60
+ @admin.register(OrganizationInvitation)
61
+ class OrganizationInvitationAdmin(admin.ModelAdmin):
62
+ list_display = [
63
+ "email_address",
64
+ "organization",
65
+ "role",
66
+ "status",
67
+ "created_at",
68
+ ]
69
+ list_filter = ["status", "role", "created_at"]
70
+ search_fields = [
71
+ "email_address",
72
+ "organization__name",
73
+ "clerk_invitation_id",
74
+ ]
75
+ readonly_fields = [
76
+ "clerk_invitation_id",
77
+ "created_at",
78
+ "updated_at",
79
+ ]
80
+ raw_id_fields = ["organization", "inviter"]
81
+ ordering = ["-created_at"]
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class OrganizationsConfig(AppConfig):
5
+ name = "django_clerk_users.organizations"
6
+ label = "clerk_organizations"
7
+ verbose_name = "Clerk Organizations"
8
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,130 @@
1
+ """
2
+ Organization middleware for django-clerk-users.
3
+
4
+ This middleware resolves Clerk organization IDs to Organization model instances.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING, Callable
11
+
12
+ from django_clerk_users.caching import (
13
+ get_cached_organization,
14
+ set_cached_organization,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from django.http import HttpRequest, HttpResponse
19
+
20
+ from django_clerk_users.organizations.models import Organization
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ClerkOrganizationMiddleware:
26
+ """
27
+ Middleware that resolves organization context.
28
+
29
+ This middleware runs after ClerkAuthMiddleware and resolves the
30
+ organization ID (from request.org) to an Organization model instance.
31
+
32
+ After processing, the middleware sets:
33
+ - request.organization: The Organization model instance (or None)
34
+
35
+ The organization ID can come from:
36
+ 1. request.org (set by ClerkAuthMiddleware from JWT payload)
37
+ 2. X-Organization-Id header (for explicit org switching)
38
+ """
39
+
40
+ def __init__(self, get_response: Callable[["HttpRequest"], "HttpResponse"]):
41
+ self.get_response = get_response
42
+
43
+ def __call__(self, request: "HttpRequest") -> "HttpResponse":
44
+ # Process organization before the view
45
+ self.process_request(request)
46
+
47
+ # Call the next middleware/view
48
+ response = self.get_response(request)
49
+
50
+ return response
51
+
52
+ def process_request(self, request: "HttpRequest") -> None:
53
+ """
54
+ Resolve the organization context.
55
+
56
+ Sets request.organization to the Organization model instance
57
+ if an organization ID is present.
58
+ """
59
+ request.organization = None # type: ignore
60
+
61
+ # Get organization ID from request
62
+ # Priority: request.org (from JWT) > X-Organization-Id header
63
+ org_id = getattr(request, "org", None)
64
+ if not org_id:
65
+ org_id = request.headers.get("X-Organization-Id")
66
+
67
+ if not org_id:
68
+ return
69
+
70
+ # Resolve to Organization model
71
+ organization = self._get_organization(org_id)
72
+ if organization:
73
+ if not self._is_member(request, organization):
74
+ logger.debug("User is not a member of org %s", org_id)
75
+ return
76
+ request.organization = organization # type: ignore
77
+ # Update request.org in case it came from header
78
+ request.org = org_id # type: ignore
79
+
80
+ def _is_member(self, request: "HttpRequest", organization: "Organization") -> bool:
81
+ """
82
+ Check whether the current user belongs to the organization.
83
+
84
+ Args:
85
+ request: The current HTTP request.
86
+ organization: The organization to check.
87
+
88
+ Returns:
89
+ True if the user is authenticated and a member.
90
+ """
91
+ if not hasattr(request, "user") or not request.user.is_authenticated:
92
+ return False
93
+
94
+ from django_clerk_users.organizations.models import OrganizationMember
95
+
96
+ return OrganizationMember.objects.filter(
97
+ organization=organization,
98
+ user=request.user,
99
+ ).exists()
100
+
101
+ def _get_organization(self, clerk_id: str) -> "Organization | None":
102
+ """
103
+ Get an Organization by Clerk ID, using cache.
104
+
105
+ Args:
106
+ clerk_id: The Clerk organization ID.
107
+
108
+ Returns:
109
+ The Organization instance or None if not found.
110
+ """
111
+ from django_clerk_users.organizations.models import Organization
112
+
113
+ # Check cache first
114
+ cached = get_cached_organization(clerk_id)
115
+ if cached is not None:
116
+ if cached is False:
117
+ return None # Cached as "not found"
118
+ return cached
119
+
120
+ # Query database
121
+ try:
122
+ organization = Organization.objects.get(
123
+ clerk_id=clerk_id,
124
+ is_active=True,
125
+ )
126
+ set_cached_organization(clerk_id, organization)
127
+ return organization
128
+ except Organization.DoesNotExist:
129
+ set_cached_organization(clerk_id, None)
130
+ return None