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
@@ -1,22 +1,93 @@
1
+ """
2
+ django-clerk-users: Integrate Clerk authentication with Django.
3
+ """
4
+
1
5
  from importlib.metadata import PackageNotFoundError, version
2
6
 
3
7
  try:
4
8
  __version__ = version("django-clerk-users")
5
9
  except PackageNotFoundError:
6
- # Package is not installed
7
10
  __version__ = "unknown"
8
11
 
12
+ # Re-export default app config
13
+ default_app_config = "django_clerk_users.apps.DjangoClerkUsersConfig"
14
+
9
15
 
10
- def __getattr__(name):
16
+ def __getattr__(name: str):
11
17
  """Lazy import to avoid loading Django models before apps are ready."""
12
- if name == "hello_world":
13
- from django_clerk_users.main import hello_world
18
+ # Models
19
+ if name == "AbstractClerkUser":
20
+ from django_clerk_users.models import AbstractClerkUser
21
+
22
+ return AbstractClerkUser
23
+ if name == "ClerkUser":
24
+ from django_clerk_users.models import ClerkUser
25
+
26
+ return ClerkUser
27
+ if name == "ClerkUserManager":
28
+ from django_clerk_users.models import ClerkUserManager
29
+
30
+ return ClerkUserManager
31
+
32
+ # Client
33
+ if name == "get_clerk_client":
34
+ from django_clerk_users.client import get_clerk_client
35
+
36
+ return get_clerk_client
37
+
38
+ # Exceptions
39
+ if name in (
40
+ "ClerkError",
41
+ "ClerkConfigurationError",
42
+ "ClerkAuthenticationError",
43
+ "ClerkTokenError",
44
+ "ClerkWebhookError",
45
+ "ClerkAPIError",
46
+ "ClerkUserNotFoundError",
47
+ "ClerkOrganizationNotFoundError",
48
+ ):
49
+ from django_clerk_users import exceptions
50
+
51
+ return getattr(exceptions, name)
52
+
53
+ # Testing utilities
54
+ if name in (
55
+ "ClerkTestClient",
56
+ "ClerkTestMixin",
57
+ "TestUserData",
58
+ "make_test_email",
59
+ "make_test_phone",
60
+ "TEST_OTP_CODE",
61
+ ):
62
+ from django_clerk_users import testing
63
+
64
+ return getattr(testing, name)
14
65
 
15
- return hello_world
16
- raise AttributeError(f"Module 'django_clerk' has no attribute '{name}'")
66
+ raise AttributeError(f"Module 'django_clerk_users' has no attribute '{name}'")
17
67
 
18
68
 
19
69
  __all__ = [
20
70
  "__version__",
21
- "hello_world",
71
+ # Models
72
+ "AbstractClerkUser",
73
+ "ClerkUser",
74
+ "ClerkUserManager",
75
+ # Client
76
+ "get_clerk_client",
77
+ # Exceptions
78
+ "ClerkError",
79
+ "ClerkConfigurationError",
80
+ "ClerkAuthenticationError",
81
+ "ClerkTokenError",
82
+ "ClerkWebhookError",
83
+ "ClerkAPIError",
84
+ "ClerkUserNotFoundError",
85
+ "ClerkOrganizationNotFoundError",
86
+ # Testing utilities
87
+ "ClerkTestClient",
88
+ "ClerkTestMixin",
89
+ "TestUserData",
90
+ "make_test_email",
91
+ "make_test_phone",
92
+ "TEST_OTP_CODE",
22
93
  ]
@@ -0,0 +1,20 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoClerkUsersConfig(AppConfig):
5
+ name = "django_clerk_users"
6
+ verbose_name = "Django Clerk Users"
7
+ default_auto_field = "django.db.models.BigAutoField"
8
+
9
+ def ready(self):
10
+ # Import checks to register them with Django
11
+ from django_clerk_users import checks # noqa: F401
12
+
13
+ # Disconnect Django's update_last_login signal
14
+ # Clerk manages authentication externally
15
+ from django.contrib.auth import get_user_model
16
+ from django.contrib.auth.models import update_last_login
17
+ from django.contrib.auth.signals import user_logged_in
18
+
19
+ User = get_user_model()
20
+ user_logged_in.disconnect(update_last_login, sender=User)
@@ -0,0 +1,24 @@
1
+ """
2
+ Authentication backends and utilities for django-clerk-users.
3
+ """
4
+
5
+ from django_clerk_users.authentication.backends import ClerkBackend
6
+ from django_clerk_users.authentication.utils import (
7
+ get_clerk_payload_from_request,
8
+ get_or_create_user_from_payload,
9
+ )
10
+
11
+ __all__ = [
12
+ "ClerkBackend",
13
+ "get_clerk_payload_from_request",
14
+ "get_or_create_user_from_payload",
15
+ ]
16
+
17
+ # Conditionally export DRF authentication if available
18
+ try:
19
+ from django_clerk_users.authentication.drf import ClerkAuthentication
20
+
21
+ __all__.append("ClerkAuthentication")
22
+ except ImportError:
23
+ # DRF is not installed
24
+ pass
@@ -0,0 +1,89 @@
1
+ """
2
+ Django authentication backend for Clerk.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from django.contrib.auth import get_user_model
11
+ from django.contrib.auth.backends import BaseBackend
12
+
13
+ if TYPE_CHECKING:
14
+ from django.http import HttpRequest
15
+
16
+ from django_clerk_users.models import AbstractClerkUser
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ClerkBackend(BaseBackend):
22
+ """
23
+ Django authentication backend for Clerk.
24
+
25
+ This backend authenticates users by their Clerk ID rather than
26
+ username/password. It's designed to work with Clerk's JWT-based
27
+ authentication.
28
+
29
+ To use this backend, add it to AUTHENTICATION_BACKENDS in settings:
30
+
31
+ AUTHENTICATION_BACKENDS = [
32
+ 'django_clerk_users.authentication.ClerkBackend',
33
+ ]
34
+ """
35
+
36
+ def authenticate(
37
+ self,
38
+ request: "HttpRequest | None" = None,
39
+ clerk_id: str | None = None,
40
+ **kwargs: Any,
41
+ ) -> "AbstractClerkUser | None":
42
+ """
43
+ Authenticate a user by their Clerk ID.
44
+
45
+ Args:
46
+ request: The current HTTP request (optional).
47
+ clerk_id: The Clerk user ID to authenticate.
48
+ **kwargs: Additional keyword arguments (ignored).
49
+
50
+ Returns:
51
+ The authenticated user or None if authentication fails.
52
+ """
53
+ if not clerk_id:
54
+ return None
55
+
56
+ User = get_user_model()
57
+
58
+ try:
59
+ user = User.objects.get(clerk_id=clerk_id)
60
+ if user.is_active:
61
+ return user
62
+ logger.debug(f"User {clerk_id} is inactive")
63
+ return None
64
+ except User.DoesNotExist:
65
+ logger.debug(f"No user found with clerk_id: {clerk_id}")
66
+ return None
67
+
68
+ def get_user(self, user_id: int) -> "AbstractClerkUser | None":
69
+ """
70
+ Get a user by their Django primary key.
71
+
72
+ This method is called by Django's authentication middleware
73
+ to restore the user from the session.
74
+
75
+ Args:
76
+ user_id: The user's primary key.
77
+
78
+ Returns:
79
+ The user instance or None if not found.
80
+ """
81
+ User = get_user_model()
82
+
83
+ try:
84
+ user = User.objects.get(pk=user_id)
85
+ if user.is_active:
86
+ return user
87
+ return None
88
+ except User.DoesNotExist:
89
+ return None
@@ -0,0 +1,111 @@
1
+ """
2
+ Django REST Framework authentication class for Clerk.
3
+
4
+ This module requires djangorestframework to be installed.
5
+ Install it with: pip install django-clerk-users[drf]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from django.http import HttpRequest
15
+
16
+ from django_clerk_users.models import AbstractClerkUser
17
+
18
+ from django_clerk_users.authentication.utils import (
19
+ get_clerk_payload_from_request,
20
+ get_or_create_user_from_payload,
21
+ )
22
+ from django_clerk_users.exceptions import ClerkAuthenticationError, ClerkTokenError
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Defer DRF import - check at class instantiation time
27
+ _drf_available = False
28
+ _drf_import_error: str | None = None
29
+
30
+ try:
31
+ from rest_framework import authentication, exceptions
32
+
33
+ _drf_available = True
34
+ _BaseAuthentication: Any = authentication.BaseAuthentication
35
+ except ImportError:
36
+ _drf_import_error = (
37
+ "Django REST Framework is required for ClerkAuthentication. "
38
+ "Install it with: pip install django-clerk-users[drf]"
39
+ )
40
+ _BaseAuthentication = object
41
+
42
+
43
+ class ClerkAuthentication(_BaseAuthentication):
44
+ """
45
+ Django REST Framework authentication class for Clerk.
46
+
47
+ This authentication class validates Clerk JWT tokens and returns
48
+ the corresponding Django user.
49
+
50
+ To use this class, add it to DEFAULT_AUTHENTICATION_CLASSES in settings:
51
+
52
+ REST_FRAMEWORK = {
53
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
54
+ 'django_clerk_users.authentication.ClerkAuthentication',
55
+ ],
56
+ }
57
+ """
58
+
59
+ def __init__(self) -> None:
60
+ if not _drf_available:
61
+ raise ImportError(_drf_import_error)
62
+ super().__init__()
63
+
64
+ def authenticate(
65
+ self, request: "HttpRequest"
66
+ ) -> tuple["AbstractClerkUser", dict] | None:
67
+ """
68
+ Authenticate the request and return a tuple of (user, auth).
69
+
70
+ Args:
71
+ request: The incoming HTTP request.
72
+
73
+ Returns:
74
+ A tuple of (user, payload) if authentication succeeds,
75
+ or None if no authentication credentials were provided.
76
+
77
+ Raises:
78
+ AuthenticationFailed: If authentication fails.
79
+ """
80
+ try:
81
+ payload = get_clerk_payload_from_request(request)
82
+ except ClerkTokenError as e:
83
+ raise exceptions.AuthenticationFailed(str(e))
84
+
85
+ if payload is None:
86
+ return None
87
+
88
+ try:
89
+ user, _ = get_or_create_user_from_payload(payload)
90
+ except ClerkAuthenticationError as e:
91
+ raise exceptions.AuthenticationFailed(str(e))
92
+
93
+ if not user.is_active:
94
+ raise exceptions.AuthenticationFailed("User is inactive.")
95
+
96
+ # Attach the Clerk payload to the request for later use
97
+ request.clerk_payload = payload # type: ignore
98
+
99
+ return (user, payload)
100
+
101
+ def authenticate_header(self, request: "HttpRequest") -> str:
102
+ """
103
+ Return a string to be used as the WWW-Authenticate header.
104
+
105
+ Args:
106
+ request: The incoming HTTP request.
107
+
108
+ Returns:
109
+ The authentication scheme identifier.
110
+ """
111
+ return "Bearer"
@@ -0,0 +1,171 @@
1
+ """
2
+ Authentication utilities for JWT token validation and user retrieval.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import logging
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from clerk_backend_api.security.types import AuthenticateRequestOptions
13
+ from django.core.cache import cache
14
+
15
+ from django_clerk_users.client import get_clerk_client
16
+ from django_clerk_users.exceptions import ClerkAuthenticationError, ClerkTokenError
17
+ from django_clerk_users.settings import CLERK_AUTH_PARTIES, CLERK_CACHE_TIMEOUT
18
+
19
+ if TYPE_CHECKING:
20
+ from django.http import HttpRequest
21
+
22
+ from django_clerk_users.models import AbstractClerkUser
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def get_bearer_token(request: "HttpRequest") -> str | None:
28
+ """
29
+ Extract the Bearer token from the Authorization header.
30
+
31
+ Args:
32
+ request: The Django HTTP request.
33
+
34
+ Returns:
35
+ The bearer token string or None if not present.
36
+ """
37
+ auth_header = request.headers.get("Authorization", "")
38
+ if auth_header.startswith("Bearer "):
39
+ return auth_header[7:] # Remove "Bearer " prefix
40
+ return None
41
+
42
+
43
+ def get_clerk_payload_from_request(request: "HttpRequest") -> dict[str, Any] | None:
44
+ """
45
+ Validate a Clerk JWT token and return the payload.
46
+
47
+ This function extracts the JWT from the Authorization header,
48
+ validates it using the Clerk SDK, and returns the decoded payload.
49
+ Results are cached to avoid repeated validation.
50
+
51
+ Args:
52
+ request: The Django HTTP request.
53
+
54
+ Returns:
55
+ The decoded JWT payload dict or None if validation fails.
56
+
57
+ Raises:
58
+ ClerkTokenError: If the token is invalid or expired.
59
+ """
60
+ token = get_bearer_token(request)
61
+ if not token:
62
+ return None
63
+
64
+ # Create a cache key based on the token hash (never store raw tokens)
65
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
66
+ cache_key = f"clerk:payload:{token_hash}"
67
+
68
+ # Check cache first
69
+ cached_payload = cache.get(cache_key)
70
+ if cached_payload is not None:
71
+ return cached_payload
72
+
73
+ try:
74
+ clerk = get_clerk_client()
75
+
76
+ # Build auth options with authorized parties
77
+ auth_options = None
78
+ if CLERK_AUTH_PARTIES:
79
+ auth_options = AuthenticateRequestOptions(
80
+ authorized_parties=CLERK_AUTH_PARTIES
81
+ )
82
+
83
+ # Validate the token using Clerk SDK
84
+ request_state = clerk.authenticate_request(request, options=auth_options)
85
+
86
+ if not request_state.is_signed_in:
87
+ logger.debug("Clerk token validation failed: not signed in")
88
+ return None
89
+
90
+ payload = request_state.payload
91
+ if not payload:
92
+ logger.debug("Clerk token validation failed: no payload")
93
+ return None
94
+
95
+ # Calculate cache timeout based on token expiration
96
+ # This ensures we never use an expired token from cache
97
+ exp = payload.get("exp")
98
+ if exp:
99
+ current_time = int(time.time())
100
+ # Cache until 1 minute before expiration, with a minimum of 60s
101
+ # and maximum of CLERK_CACHE_TIMEOUT (default 5 minutes)
102
+ time_until_exp = exp - current_time
103
+ cache_timeout = max(60, min(time_until_exp - 60, CLERK_CACHE_TIMEOUT))
104
+ else:
105
+ # Default to cache timeout if no exp claim
106
+ cache_timeout = CLERK_CACHE_TIMEOUT
107
+
108
+ cache.set(cache_key, payload, timeout=cache_timeout)
109
+ logger.debug(f"Cached Clerk payload for {cache_timeout} seconds")
110
+
111
+ return payload
112
+
113
+ except Exception as e:
114
+ logger.warning(f"Clerk token validation error: {e}")
115
+ raise ClerkTokenError(f"Token validation failed: {e}") from e
116
+
117
+
118
+ def get_or_create_user_from_payload(
119
+ payload: dict[str, Any],
120
+ ) -> tuple["AbstractClerkUser", bool]:
121
+ """
122
+ Get or create a Django user from a Clerk JWT payload.
123
+
124
+ Args:
125
+ payload: The decoded Clerk JWT payload.
126
+
127
+ Returns:
128
+ A tuple of (user, created) where created is True if the user was newly created.
129
+
130
+ Raises:
131
+ ClerkAuthenticationError: If the payload is invalid or user creation fails.
132
+ """
133
+ from django.contrib.auth import get_user_model
134
+
135
+ User = get_user_model()
136
+
137
+ clerk_user_id = payload.get("sub")
138
+ if not clerk_user_id:
139
+ raise ClerkAuthenticationError("Invalid payload: missing 'sub' claim")
140
+
141
+ # Try to get existing user first
142
+ user = User.objects.filter(clerk_id=clerk_user_id).first()
143
+ if user:
144
+ return user, False
145
+
146
+ # User doesn't exist - we need to create them
147
+ # Fetch full user data from Clerk API
148
+ try:
149
+ from django_clerk_users.utils import update_or_create_clerk_user
150
+
151
+ user, created = update_or_create_clerk_user(clerk_user_id)
152
+ return user, created
153
+ except Exception as e:
154
+ logger.error(f"Failed to create user from Clerk: {e}")
155
+ raise ClerkAuthenticationError(f"Failed to create user: {e}") from e
156
+
157
+
158
+ def get_user_from_clerk_id(clerk_id: str) -> "AbstractClerkUser | None":
159
+ """
160
+ Get a Django user by their Clerk ID.
161
+
162
+ Args:
163
+ clerk_id: The Clerk user ID.
164
+
165
+ Returns:
166
+ The user instance or None if not found.
167
+ """
168
+ from django.contrib.auth import get_user_model
169
+
170
+ User = get_user_model()
171
+ return User.objects.filter(clerk_id=clerk_id).first()
@@ -0,0 +1,161 @@
1
+ """
2
+ Caching utilities for django-clerk-users.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.core.cache import cache
11
+
12
+ from django_clerk_users.settings import CLERK_CACHE_TIMEOUT, CLERK_ORG_CACHE_TIMEOUT
13
+
14
+ if TYPE_CHECKING:
15
+ from django_clerk_users.models import AbstractClerkUser
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ # Cache key prefixes
21
+ USER_CACHE_PREFIX = "clerk:user:"
22
+ ORG_CACHE_PREFIX = "clerk:org:"
23
+
24
+
25
+ def get_user_cache_key(clerk_id: str) -> str:
26
+ """Get the cache key for a user."""
27
+ return f"{USER_CACHE_PREFIX}{clerk_id}"
28
+
29
+
30
+ def get_org_cache_key(clerk_id: str) -> str:
31
+ """Get the cache key for an organization."""
32
+ return f"{ORG_CACHE_PREFIX}{clerk_id}"
33
+
34
+
35
+ def get_cached_user(clerk_id: str, query_db: bool = True) -> "AbstractClerkUser | None":
36
+ """
37
+ Get a user from cache or database.
38
+
39
+ Args:
40
+ clerk_id: The Clerk user ID.
41
+ query_db: If True, query database on cache miss and cache the result.
42
+
43
+ Returns:
44
+ The user instance, or None if not found.
45
+ """
46
+ from django.contrib.auth import get_user_model
47
+
48
+ if not clerk_id:
49
+ return None
50
+
51
+ cache_key = get_user_cache_key(clerk_id)
52
+ cached_user = cache.get(cache_key)
53
+
54
+ if cached_user is not None:
55
+ # Cache hit - could be a User instance or False (cached "not found")
56
+ return cached_user if cached_user is not False else None
57
+
58
+ if not query_db:
59
+ return None
60
+
61
+ # Cache miss - query database
62
+ User = get_user_model()
63
+ try:
64
+ user = User.objects.get(clerk_id=clerk_id, is_active=True)
65
+ # Cache the user instance
66
+ cache.set(cache_key, user, timeout=CLERK_CACHE_TIMEOUT)
67
+ return user
68
+ except User.DoesNotExist:
69
+ # Cache the "not found" result to prevent repeated DB queries
70
+ cache.set(cache_key, False, timeout=CLERK_CACHE_TIMEOUT)
71
+ return None
72
+
73
+
74
+ def set_cached_user(clerk_id: str, user: "AbstractClerkUser | None") -> None:
75
+ """
76
+ Cache a user by Clerk ID.
77
+
78
+ Args:
79
+ clerk_id: The Clerk user ID.
80
+ user: The user to cache, or None to cache "not found".
81
+ """
82
+ cache_key = get_user_cache_key(clerk_id)
83
+ # Cache False for "not found" to distinguish from "not cached"
84
+ value = user if user is not None else False
85
+ cache.set(cache_key, value, timeout=CLERK_CACHE_TIMEOUT)
86
+
87
+
88
+ def invalidate_clerk_user_cache(clerk_id: str) -> None:
89
+ """
90
+ Invalidate the cache for a user.
91
+
92
+ Args:
93
+ clerk_id: The Clerk user ID.
94
+ """
95
+ cache_key = get_user_cache_key(clerk_id)
96
+ cache.delete(cache_key)
97
+ logger.debug(f"Invalidated user cache: {clerk_id}")
98
+
99
+
100
+ def get_cached_organization(clerk_id: str, query_db: bool = True):
101
+ """
102
+ Get an organization from cache or database.
103
+
104
+ Args:
105
+ clerk_id: The Clerk organization ID.
106
+ query_db: If True, query database on cache miss and cache the result.
107
+
108
+ Returns:
109
+ The organization instance, or None if not found.
110
+ """
111
+ if not clerk_id:
112
+ return None
113
+
114
+ cache_key = get_org_cache_key(clerk_id)
115
+ cached_org = cache.get(cache_key)
116
+
117
+ if cached_org is not None:
118
+ # Cache hit - could be an Organization instance or False (cached "not found")
119
+ return cached_org if cached_org is not False else None
120
+
121
+ if not query_db:
122
+ return None
123
+
124
+ # Cache miss - query database
125
+ try:
126
+ from django_clerk_users.organizations.models import Organization
127
+
128
+ org = Organization.objects.get(clerk_id=clerk_id, is_active=True)
129
+ # Cache the organization instance
130
+ cache.set(cache_key, org, timeout=CLERK_ORG_CACHE_TIMEOUT)
131
+ return org
132
+ except Exception:
133
+ # Cache the "not found" result to prevent repeated DB queries
134
+ cache.set(cache_key, False, timeout=CLERK_ORG_CACHE_TIMEOUT)
135
+ return None
136
+
137
+
138
+ def set_cached_organization(clerk_id: str, organization) -> None:
139
+ """
140
+ Cache an organization by Clerk ID.
141
+
142
+ Args:
143
+ clerk_id: The Clerk organization ID.
144
+ organization: The organization to cache, or None to cache "not found".
145
+ """
146
+ cache_key = get_org_cache_key(clerk_id)
147
+ # Cache False for "not found" to distinguish from "not cached"
148
+ value = organization if organization is not None else False
149
+ cache.set(cache_key, value, timeout=CLERK_ORG_CACHE_TIMEOUT)
150
+
151
+
152
+ def invalidate_organization_cache(clerk_id: str) -> None:
153
+ """
154
+ Invalidate the cache for an organization.
155
+
156
+ Args:
157
+ clerk_id: The Clerk organization ID.
158
+ """
159
+ cache_key = get_org_cache_key(clerk_id)
160
+ cache.delete(cache_key)
161
+ logger.debug(f"Invalidated organization cache: {clerk_id}")