ccp4i2-api 0.3.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.
- ccp4i2_api/__init__.py +13 -0
- ccp4i2_api/drf.py +58 -0
- ccp4i2_api/exceptions.py +18 -0
- ccp4i2_api/middleware/__init__.py +23 -0
- ccp4i2_api/middleware/azure_ad.py +413 -0
- ccp4i2_api/middleware/base.py +69 -0
- ccp4i2_api/middleware/dev.py +48 -0
- ccp4i2_api/middleware/dev_admin.py +65 -0
- ccp4i2_api/middleware/local_session.py +65 -0
- ccp4i2_api-0.3.0.dist-info/METADATA +98 -0
- ccp4i2_api-0.3.0.dist-info/RECORD +12 -0
- ccp4i2_api-0.3.0.dist-info/WHEEL +4 -0
ccp4i2_api/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared API contract for CCP4i2 and consumers.
|
|
2
|
+
|
|
3
|
+
This package is the Python half of a bilingual API library; the TypeScript
|
|
4
|
+
half is published as ``@ccp4/ccp4i2-api``. Both halves implement the same
|
|
5
|
+
canonical bearer-token format, 401 response shape, and typed payloads
|
|
6
|
+
carried over the authenticated channel.
|
|
7
|
+
|
|
8
|
+
See the package README for current status and
|
|
9
|
+
``docs/CCP4I2_SERVICE_CONTRACT.md`` in the ccp4i2 monorepo for the
|
|
10
|
+
contract specification.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.3.0"
|
ccp4i2_api/drf.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""DRF authentication classes for CCP4i2.
|
|
2
|
+
|
|
3
|
+
These classes work in tandem with the middleware in
|
|
4
|
+
``ccp4i2_api.middleware`` — the middleware validates the bearer token
|
|
5
|
+
and sets ``request.user`` plus the ``REQUEST_FLAG_ATTR`` trust flag; the
|
|
6
|
+
DRF auth class checks the flag and surfaces ``request.user`` to DRF's
|
|
7
|
+
``IsAuthenticated`` permission.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from rest_framework.authentication import BaseAuthentication
|
|
11
|
+
|
|
12
|
+
from .middleware.base import REQUEST_FLAG_ATTR
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AzureADAuthentication(BaseAuthentication):
|
|
16
|
+
"""
|
|
17
|
+
DRF authentication class that uses the user set by AzureADAuthMiddleware.
|
|
18
|
+
|
|
19
|
+
This allows DRF's IsAuthenticated permission to work with our middleware.
|
|
20
|
+
The middleware does the actual JWT validation; this class just passes
|
|
21
|
+
the authenticated user to DRF.
|
|
22
|
+
|
|
23
|
+
In dev/Electron mode (CCP4I2_REQUIRE_AUTH not set), the middleware
|
|
24
|
+
auto-assigns a dev_admin user, which this class also recognizes.
|
|
25
|
+
|
|
26
|
+
Security: Only trusts users when our middleware has explicitly processed
|
|
27
|
+
the request (marked by ``REQUEST_FLAG_ATTR`` attribute). This prevents
|
|
28
|
+
spoofing attacks where a request might have ``request.user`` set by
|
|
29
|
+
some other means.
|
|
30
|
+
|
|
31
|
+
Note: this class is bound to the trust flag, not to the AzureAD chain
|
|
32
|
+
specifically; it works equally for any middleware that inherits from
|
|
33
|
+
``BaseAuthMiddleware`` (e.g., LocalSessionAuthMiddleware in desktop
|
|
34
|
+
mode), because they all set the same flag.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def authenticate(self, request):
|
|
38
|
+
"""
|
|
39
|
+
Return the user if already authenticated by middleware, None otherwise.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Tuple of (user, None) if authenticated, None if not.
|
|
43
|
+
"""
|
|
44
|
+
# Check if middleware already validated and set user
|
|
45
|
+
# The middleware sets these attributes on the underlying Django request
|
|
46
|
+
django_request = getattr(request, '_request', request)
|
|
47
|
+
|
|
48
|
+
# Security check: only trust users set by our middleware
|
|
49
|
+
# This prevents spoofing where request.user might be set elsewhere
|
|
50
|
+
if not getattr(django_request, REQUEST_FLAG_ATTR, False):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
# Middleware ran - trust the user it set
|
|
54
|
+
user = getattr(django_request, 'user', None)
|
|
55
|
+
if user and user.is_authenticated and not user.is_anonymous:
|
|
56
|
+
return (user, None)
|
|
57
|
+
|
|
58
|
+
return None
|
ccp4i2_api/exceptions.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Exceptions used by the shared auth middleware contract."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthenticationFailed(Exception):
|
|
5
|
+
"""Raised by ``BaseAuthMiddleware.authenticate()`` to signal a 401.
|
|
6
|
+
|
|
7
|
+
The exception's first argument is the message returned in the canonical
|
|
8
|
+
401 response body (``{"success": false, "error": "..."}``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthorizationFailed(Exception):
|
|
13
|
+
"""Raised by ``BaseAuthMiddleware.authenticate()`` to signal a 403.
|
|
14
|
+
|
|
15
|
+
Use when the request is *authenticated* (we know who is calling) but
|
|
16
|
+
*not authorized* (e.g., not a member of an allowed group). The first
|
|
17
|
+
argument is the message returned in the canonical 403 response body.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Django middleware shipped by the shared auth package.
|
|
2
|
+
|
|
3
|
+
Each module in this package implements one auth scheme; consumers select
|
|
4
|
+
which to enable via Django ``MIDDLEWARE`` settings. All non-dev schemes
|
|
5
|
+
inherit from ``BaseAuthMiddleware`` (or will, after the AzureAD refactor),
|
|
6
|
+
which defines the canonical 401 response shape and the ``REQUEST_FLAG_ATTR``
|
|
7
|
+
trust signal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .azure_ad import AzureADAuthMiddleware
|
|
11
|
+
from .base import REQUEST_FLAG_ATTR, BaseAuthMiddleware
|
|
12
|
+
from .dev import DevAuthMiddleware
|
|
13
|
+
from .dev_admin import DevAdminMiddleware
|
|
14
|
+
from .local_session import LocalSessionAuthMiddleware
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AzureADAuthMiddleware",
|
|
18
|
+
"BaseAuthMiddleware",
|
|
19
|
+
"DevAdminMiddleware",
|
|
20
|
+
"DevAuthMiddleware",
|
|
21
|
+
"LocalSessionAuthMiddleware",
|
|
22
|
+
"REQUEST_FLAG_ATTR",
|
|
23
|
+
]
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Azure AD JWT validation middleware for CCP4i2.
|
|
3
|
+
|
|
4
|
+
This middleware validates Azure AD JWT tokens on incoming requests when
|
|
5
|
+
CCP4I2_REQUIRE_AUTH=true is set. It supports both:
|
|
6
|
+
|
|
7
|
+
1. Authorization header: "Bearer <token>"
|
|
8
|
+
2. X-MS-TOKEN-AAD-ACCESS-TOKEN header (set by Azure Container Apps Easy Auth)
|
|
9
|
+
|
|
10
|
+
Configuration environment variables:
|
|
11
|
+
- CCP4I2_REQUIRE_AUTH: Set to "true" to enable authentication (default: false)
|
|
12
|
+
- AZURE_AD_TENANT_ID: Your Azure AD tenant ID
|
|
13
|
+
- AZURE_AD_CLIENT_ID: Your Azure AD app registration client ID
|
|
14
|
+
|
|
15
|
+
The middleware validates:
|
|
16
|
+
- Token signature (using Azure AD's public keys)
|
|
17
|
+
- Token expiration
|
|
18
|
+
- Audience (must match client ID)
|
|
19
|
+
- Issuer (must match Azure AD tenant)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import ssl
|
|
26
|
+
import time
|
|
27
|
+
from typing import Optional, Tuple
|
|
28
|
+
from urllib.error import URLError
|
|
29
|
+
from urllib.request import urlopen
|
|
30
|
+
|
|
31
|
+
import certifi
|
|
32
|
+
from django.contrib.auth import get_user_model
|
|
33
|
+
from django.http import HttpRequest, HttpResponse
|
|
34
|
+
|
|
35
|
+
from ..exceptions import AuthenticationFailed, AuthorizationFailed
|
|
36
|
+
from .base import BaseAuthMiddleware
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AzureADTokenValidator:
|
|
42
|
+
"""Validates Azure AD JWT tokens."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, tenant_id: str, client_id: str):
|
|
45
|
+
self.tenant_id = tenant_id
|
|
46
|
+
self.client_id = client_id
|
|
47
|
+
self.issuer = f"https://login.microsoftonline.com/{tenant_id}/v2.0"
|
|
48
|
+
self.jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
|
|
49
|
+
self._keys_cache: Optional[dict] = None
|
|
50
|
+
self._keys_cache_time: float = 0
|
|
51
|
+
self._keys_cache_ttl: float = 3600 # 1 hour
|
|
52
|
+
|
|
53
|
+
def _get_signing_keys(self) -> dict:
|
|
54
|
+
"""Fetch Azure AD's public signing keys (JWKS)."""
|
|
55
|
+
now = time.time()
|
|
56
|
+
|
|
57
|
+
# Return cached keys if still valid
|
|
58
|
+
if self._keys_cache and (now - self._keys_cache_time) < self._keys_cache_ttl:
|
|
59
|
+
return self._keys_cache
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Use certifi's certificate bundle for SSL verification
|
|
63
|
+
ssl_context = ssl.create_default_context(cafile=certifi.where())
|
|
64
|
+
with urlopen(self.jwks_uri, timeout=10, context=ssl_context) as response:
|
|
65
|
+
jwks = json.loads(response.read().decode("utf-8"))
|
|
66
|
+
self._keys_cache = {key["kid"]: key for key in jwks.get("keys", [])}
|
|
67
|
+
self._keys_cache_time = now
|
|
68
|
+
logger.debug(f"Fetched {len(self._keys_cache)} signing keys from Azure AD")
|
|
69
|
+
return self._keys_cache
|
|
70
|
+
except URLError as e:
|
|
71
|
+
logger.error(f"Failed to fetch Azure AD signing keys: {e}")
|
|
72
|
+
# Return stale cache if available, otherwise raise
|
|
73
|
+
if self._keys_cache:
|
|
74
|
+
logger.warning("Using stale signing keys cache")
|
|
75
|
+
return self._keys_cache
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
def validate_token(self, token: str) -> Tuple[bool, Optional[dict], Optional[str]]:
|
|
79
|
+
"""
|
|
80
|
+
Validate a JWT token.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tuple of (is_valid, claims_dict, error_message)
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
import jwt
|
|
87
|
+
from jwt import PyJWK
|
|
88
|
+
except ImportError:
|
|
89
|
+
logger.error("PyJWT not installed. Run: pip install PyJWT[crypto]")
|
|
90
|
+
return False, None, "Server configuration error: PyJWT not installed"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# Decode header to get key ID
|
|
94
|
+
unverified_header = jwt.get_unverified_header(token)
|
|
95
|
+
kid = unverified_header.get("kid")
|
|
96
|
+
|
|
97
|
+
if not kid:
|
|
98
|
+
return False, None, "Token missing key ID (kid)"
|
|
99
|
+
|
|
100
|
+
# Get the signing key
|
|
101
|
+
keys = self._get_signing_keys()
|
|
102
|
+
if kid not in keys:
|
|
103
|
+
# Key not found - try refreshing the cache
|
|
104
|
+
self._keys_cache = None
|
|
105
|
+
keys = self._get_signing_keys()
|
|
106
|
+
if kid not in keys:
|
|
107
|
+
return False, None, f"Unknown signing key: {kid}"
|
|
108
|
+
|
|
109
|
+
key_data = keys[kid]
|
|
110
|
+
|
|
111
|
+
# Construct signing key directly from cached JWK data
|
|
112
|
+
# (avoids PyJWKClient making its own HTTPS request)
|
|
113
|
+
jwk = PyJWK.from_dict(key_data)
|
|
114
|
+
signing_key = jwk.key
|
|
115
|
+
|
|
116
|
+
# Decode and validate the token
|
|
117
|
+
claims = jwt.decode(
|
|
118
|
+
token,
|
|
119
|
+
signing_key,
|
|
120
|
+
algorithms=["RS256"],
|
|
121
|
+
audience=self.client_id,
|
|
122
|
+
issuer=self.issuer,
|
|
123
|
+
options={
|
|
124
|
+
"verify_signature": True,
|
|
125
|
+
"verify_exp": True,
|
|
126
|
+
"verify_aud": True,
|
|
127
|
+
"verify_iss": True,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
logger.debug(f"Token validated for subject: {claims.get('sub', 'unknown')}")
|
|
132
|
+
return True, claims, None
|
|
133
|
+
|
|
134
|
+
except jwt.ExpiredSignatureError:
|
|
135
|
+
return False, None, "Token has expired"
|
|
136
|
+
except jwt.InvalidAudienceError:
|
|
137
|
+
return False, None, "Invalid token audience"
|
|
138
|
+
except jwt.InvalidIssuerError:
|
|
139
|
+
return False, None, "Invalid token issuer"
|
|
140
|
+
except jwt.InvalidSignatureError:
|
|
141
|
+
return False, None, "Invalid token signature"
|
|
142
|
+
except jwt.DecodeError as e:
|
|
143
|
+
return False, None, f"Token decode error: {e}"
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.exception("Unexpected error validating token")
|
|
146
|
+
return False, None, f"Token validation error: {e}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# Singleton validator instance
|
|
150
|
+
_validator: Optional[AzureADTokenValidator] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_validator() -> Optional[AzureADTokenValidator]:
|
|
154
|
+
"""Get or create the token validator instance."""
|
|
155
|
+
global _validator
|
|
156
|
+
|
|
157
|
+
if _validator is not None:
|
|
158
|
+
return _validator
|
|
159
|
+
|
|
160
|
+
tenant_id = os.environ.get("AZURE_AD_TENANT_ID")
|
|
161
|
+
client_id = os.environ.get("AZURE_AD_CLIENT_ID")
|
|
162
|
+
|
|
163
|
+
if not tenant_id or not client_id:
|
|
164
|
+
logger.warning(
|
|
165
|
+
"AZURE_AD_TENANT_ID and AZURE_AD_CLIENT_ID must be set for authentication. "
|
|
166
|
+
"Authentication is disabled."
|
|
167
|
+
)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
_validator = AzureADTokenValidator(tenant_id, client_id)
|
|
171
|
+
return _validator
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def is_auth_required() -> bool:
|
|
175
|
+
"""Check if authentication is required."""
|
|
176
|
+
return os.environ.get("CCP4I2_REQUIRE_AUTH", "").lower() in ("true", "1", "yes")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def extract_token(request: HttpRequest) -> Optional[str]:
|
|
180
|
+
"""
|
|
181
|
+
Extract JWT token from request.
|
|
182
|
+
|
|
183
|
+
Checks in order:
|
|
184
|
+
1. Authorization header (Bearer token)
|
|
185
|
+
2. X-MS-TOKEN-AAD-ACCESS-TOKEN header (Azure Easy Auth)
|
|
186
|
+
3. Query parameter access_token (for file downloads/anchor links)
|
|
187
|
+
"""
|
|
188
|
+
# Check Authorization header
|
|
189
|
+
auth_header = request.headers.get("Authorization", "")
|
|
190
|
+
if auth_header.startswith("Bearer "):
|
|
191
|
+
return auth_header[7:]
|
|
192
|
+
|
|
193
|
+
# Check Azure Easy Auth header
|
|
194
|
+
easy_auth_token = request.headers.get("X-MS-TOKEN-AAD-ACCESS-TOKEN")
|
|
195
|
+
if easy_auth_token:
|
|
196
|
+
return easy_auth_token
|
|
197
|
+
|
|
198
|
+
# Check query parameter (for file downloads - anchor links don't send headers)
|
|
199
|
+
query_token = request.GET.get("access_token")
|
|
200
|
+
if query_token:
|
|
201
|
+
return query_token
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AzureADAuthMiddleware(BaseAuthMiddleware):
|
|
207
|
+
"""
|
|
208
|
+
Django middleware for Azure AD JWT validation. Cloud auth path.
|
|
209
|
+
|
|
210
|
+
Activates when ``CCP4I2_REQUIRE_AUTH=true`` (otherwise no-op, deferring
|
|
211
|
+
to whatever middleware comes next in the chain). When active, every
|
|
212
|
+
non-exempt request must carry a valid Azure AD JWT bearer token; the
|
|
213
|
+
middleware validates it, optionally enforces group-membership rules,
|
|
214
|
+
and gets-or-creates a Django user keyed on the cryptographic ``sub``.
|
|
215
|
+
|
|
216
|
+
The dev_admin auto-login path was deliberately split out into a
|
|
217
|
+
separate ``DevAdminMiddleware`` so a misconfigured cloud deploy
|
|
218
|
+
(REQUIRE_AUTH unset) cannot fall through to creating a superuser
|
|
219
|
+
automatically. See ``ccp4i2_api.middleware.dev_admin`` for the dev
|
|
220
|
+
path; the CCP4i2 settings module is responsible for picking exactly
|
|
221
|
+
one auth middleware based on the deployment shape.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
# Paths that don't require authentication even when this middleware is active.
|
|
225
|
+
EXEMPT_PATHS = [
|
|
226
|
+
"/health",
|
|
227
|
+
"/healthz",
|
|
228
|
+
"/ready",
|
|
229
|
+
"/api/health",
|
|
230
|
+
"/api/ccp4i2/health",
|
|
231
|
+
"/api/ccp4i2/version",
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
def __init__(self, get_response):
|
|
235
|
+
super().__init__(get_response)
|
|
236
|
+
if self.is_active():
|
|
237
|
+
logger.info("Azure AD authentication is ENABLED")
|
|
238
|
+
validator = get_validator()
|
|
239
|
+
if not validator:
|
|
240
|
+
logger.error(
|
|
241
|
+
"Authentication required but AZURE_AD_TENANT_ID/AZURE_AD_CLIENT_ID not set!"
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
logger.info("Azure AD authentication is DISABLED (CCP4I2_REQUIRE_AUTH not set)")
|
|
245
|
+
|
|
246
|
+
def is_active(self) -> bool:
|
|
247
|
+
return is_auth_required()
|
|
248
|
+
|
|
249
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
250
|
+
# Filter exempt paths *before* delegating to BaseAuthMiddleware so
|
|
251
|
+
# health checks and version probes always bypass authentication.
|
|
252
|
+
if self.is_active():
|
|
253
|
+
path = request.path.rstrip("/")
|
|
254
|
+
if any(path == exempt.rstrip("/") for exempt in self.EXEMPT_PATHS):
|
|
255
|
+
return self.get_response(request)
|
|
256
|
+
return super().__call__(request)
|
|
257
|
+
|
|
258
|
+
def authenticate(self, request: HttpRequest):
|
|
259
|
+
token = extract_token(request)
|
|
260
|
+
if not token:
|
|
261
|
+
raise AuthenticationFailed(
|
|
262
|
+
"Authentication required. Provide Authorization: Bearer <token>"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
validator = get_validator()
|
|
266
|
+
if not validator:
|
|
267
|
+
# Server misconfiguration — AZURE_AD_TENANT_ID/CLIENT_ID missing.
|
|
268
|
+
# We surface this as a 401 because to the *caller* the result is
|
|
269
|
+
# the same as a token rejection: no auth happened, retry doesn't
|
|
270
|
+
# help. The error message and the operator-facing log line above
|
|
271
|
+
# tell the operator what to fix.
|
|
272
|
+
raise AuthenticationFailed("Server authentication not configured")
|
|
273
|
+
|
|
274
|
+
is_valid, claims, error = validator.validate_token(token)
|
|
275
|
+
if not is_valid:
|
|
276
|
+
raise AuthenticationFailed(error)
|
|
277
|
+
|
|
278
|
+
# Attach claims to request for downstream use.
|
|
279
|
+
request.azure_ad_claims = claims
|
|
280
|
+
azure_ad_sub = claims.get("sub")
|
|
281
|
+
request.azure_ad_user_id = azure_ad_sub
|
|
282
|
+
|
|
283
|
+
# Groups / Teams membership authorization.
|
|
284
|
+
self._enforce_group_membership(claims, azure_ad_sub)
|
|
285
|
+
|
|
286
|
+
email = self._extract_email(claims, request, azure_ad_sub)
|
|
287
|
+
request.azure_ad_email = email
|
|
288
|
+
|
|
289
|
+
return self._get_or_create_user(claims, azure_ad_sub, email)
|
|
290
|
+
|
|
291
|
+
# --- helper extractions kept private ------------------------------------
|
|
292
|
+
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _enforce_group_membership(claims: dict, azure_ad_sub: str) -> None:
|
|
295
|
+
"""Raise AuthorizationFailed if ALLOWED_AZURE_AD_GROUPS is set and
|
|
296
|
+
the user is not a member of any allowed group.
|
|
297
|
+
|
|
298
|
+
Requires:
|
|
299
|
+
1. Azure AD app configured to emit 'groups' claim (Token Configuration).
|
|
300
|
+
2. ALLOWED_AZURE_AD_GROUPS env var with comma-separated group IDs.
|
|
301
|
+
3. Azure AD Premium P1/P2 (for groups claim in tokens).
|
|
302
|
+
"""
|
|
303
|
+
allowed_groups_str = os.environ.get("ALLOWED_AZURE_AD_GROUPS", "")
|
|
304
|
+
allowed_groups = [g.strip() for g in allowed_groups_str.split(",") if g.strip()]
|
|
305
|
+
if not allowed_groups:
|
|
306
|
+
logger.debug("Groups authorization not configured (ALLOWED_AZURE_AD_GROUPS not set)")
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
logger.debug(f"Groups authorization enabled. Allowed groups: {allowed_groups}")
|
|
310
|
+
|
|
311
|
+
# Group claims overage — user is in >200 groups, Azure AD substitutes
|
|
312
|
+
# _claim_names/_claim_sources for the full list. We can't validate
|
|
313
|
+
# locally; surface a friendly 403 so an admin can move the user to a
|
|
314
|
+
# dedicated app-access group with fewer members.
|
|
315
|
+
if "_claim_names" in claims or "_claim_sources" in claims:
|
|
316
|
+
logger.warning(
|
|
317
|
+
f"Group claims overage detected for user {azure_ad_sub[:8]}. "
|
|
318
|
+
"User is in >200 groups - cannot validate Teams membership from token. "
|
|
319
|
+
"Consider using a dedicated app access group with fewer members."
|
|
320
|
+
)
|
|
321
|
+
raise AuthorizationFailed(
|
|
322
|
+
"Your account has too many group memberships to verify "
|
|
323
|
+
"automatically. Please contact your administrator to be "
|
|
324
|
+
"added to a dedicated application access group."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
user_groups = claims.get("groups", [])
|
|
328
|
+
logger.debug(f"User {azure_ad_sub[:8]}... has groups: {user_groups}")
|
|
329
|
+
|
|
330
|
+
if not any(group_id in allowed_groups for group_id in user_groups):
|
|
331
|
+
logger.warning(
|
|
332
|
+
f"Access denied for user {azure_ad_sub[:8]}... - not in authorized groups. "
|
|
333
|
+
f"User groups: {user_groups[:5]}{'...' if len(user_groups) > 5 else ''}, "
|
|
334
|
+
f"Required: {allowed_groups}"
|
|
335
|
+
)
|
|
336
|
+
raise AuthorizationFailed(
|
|
337
|
+
"You are not a member of an authorized group. This application "
|
|
338
|
+
"requires membership in the Newcastle Drug Discovery Unit team. "
|
|
339
|
+
"Please contact your administrator to request access."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
logger.info(f"User {azure_ad_sub[:8]}... authorized via Teams/Groups membership")
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _extract_email(claims: dict, request: HttpRequest, azure_ad_sub: str) -> str:
|
|
346
|
+
# Try multiple claim fields where email might be found.
|
|
347
|
+
email = (
|
|
348
|
+
claims.get("email")
|
|
349
|
+
or claims.get("preferred_username")
|
|
350
|
+
or claims.get("upn") # User Principal Name
|
|
351
|
+
or claims.get("unique_name") # Legacy claim
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Fallback: X-User-Email header (sent by frontend from MSAL account info).
|
|
355
|
+
# Secure because (1) JWT is already validated so we know WHO via 'sub';
|
|
356
|
+
# (2) 'sub' is the primary key, not email; (3) email is just for display.
|
|
357
|
+
if not email:
|
|
358
|
+
header_email = request.headers.get("X-User-Email")
|
|
359
|
+
if header_email:
|
|
360
|
+
logger.info(f"Using X-User-Email header for sub={azure_ad_sub[:8]}...")
|
|
361
|
+
email = header_email
|
|
362
|
+
|
|
363
|
+
if not email:
|
|
364
|
+
logger.warning(f"No email found for sub={azure_ad_sub}. Claims: {list(claims.keys())}")
|
|
365
|
+
email = f"user_{azure_ad_sub[:8]}@azuread.local"
|
|
366
|
+
|
|
367
|
+
return email
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _get_or_create_user(claims: dict, azure_ad_sub: str, email: str):
|
|
371
|
+
# Extract name from claims (priority: given_name/family_name → 'name' → empty).
|
|
372
|
+
first_name = claims.get("given_name", "")
|
|
373
|
+
last_name = claims.get("family_name", "")
|
|
374
|
+
if not first_name and not last_name:
|
|
375
|
+
full_name = claims.get("name", "")
|
|
376
|
+
if full_name:
|
|
377
|
+
name_parts = full_name.strip().split()
|
|
378
|
+
if len(name_parts) >= 2:
|
|
379
|
+
first_name = name_parts[0]
|
|
380
|
+
last_name = " ".join(name_parts[1:])
|
|
381
|
+
elif len(name_parts) == 1:
|
|
382
|
+
first_name = name_parts[0]
|
|
383
|
+
|
|
384
|
+
# Use 'sub' as the unique identifier (cryptographically verified) —
|
|
385
|
+
# prevents email-header spoofing from enabling impersonation.
|
|
386
|
+
User = get_user_model()
|
|
387
|
+
username = f"aad_{azure_ad_sub[:32]}" # Stable username from sub
|
|
388
|
+
user, created = User.objects.get_or_create(
|
|
389
|
+
username=username,
|
|
390
|
+
defaults={
|
|
391
|
+
"email": email.lower(),
|
|
392
|
+
"first_name": first_name,
|
|
393
|
+
"last_name": last_name,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
if created:
|
|
397
|
+
logger.info(f"Created user from Azure AD: {email} (sub={azure_ad_sub[:8]}...)")
|
|
398
|
+
|
|
399
|
+
# Update email and name if they changed or were initially missing.
|
|
400
|
+
updated = False
|
|
401
|
+
if user.email != email.lower():
|
|
402
|
+
user.email = email.lower()
|
|
403
|
+
updated = True
|
|
404
|
+
if first_name and user.first_name != first_name:
|
|
405
|
+
user.first_name = first_name
|
|
406
|
+
updated = True
|
|
407
|
+
if last_name and user.last_name != last_name:
|
|
408
|
+
user.last_name = last_name
|
|
409
|
+
updated = True
|
|
410
|
+
if updated:
|
|
411
|
+
user.save(update_fields=["email", "first_name", "last_name"])
|
|
412
|
+
|
|
413
|
+
return user
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Common base class for CCP4i2 auth middleware.
|
|
2
|
+
|
|
3
|
+
Subclasses implement ``is_active()`` (whether this middleware is configured
|
|
4
|
+
to operate in the current process) and ``authenticate(request)`` (the
|
|
5
|
+
auth-specific token-validation logic). The base class handles the rest of
|
|
6
|
+
the request lifecycle: the canonical 401 response shape, setting
|
|
7
|
+
``request.user``, and setting the trust flag that the matching DRF
|
|
8
|
+
authentication class checks before honouring ``request.user`` (anti-
|
|
9
|
+
spoofing, mirroring the existing AzureADAuthMiddleware contract).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
13
|
+
|
|
14
|
+
from ..exceptions import AuthenticationFailed, AuthorizationFailed
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Attribute name set on ``request`` after a successful authentication.
|
|
18
|
+
# The DRF authentication class trusts ``request.user`` only when this is
|
|
19
|
+
# set, preventing spoofing via direct attribute writes from other code.
|
|
20
|
+
REQUEST_FLAG_ATTR = "_ccp4i2_api_middleware_ran"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseAuthMiddleware:
|
|
24
|
+
"""Abstract base for CCP4i2 auth middleware.
|
|
25
|
+
|
|
26
|
+
Subclasses must implement:
|
|
27
|
+
|
|
28
|
+
* ``is_active(self) -> bool`` — return True iff this middleware should
|
|
29
|
+
attempt to authenticate. Typical implementation checks an env var
|
|
30
|
+
or Django setting.
|
|
31
|
+
* ``authenticate(self, request) -> User`` — return a Django User on
|
|
32
|
+
successful auth. Raise ``AuthenticationFailed`` to signal a 401.
|
|
33
|
+
|
|
34
|
+
When ``is_active()`` is False, the middleware is a no-op (the request
|
|
35
|
+
flows to the next middleware unchanged). This lets multiple subclasses
|
|
36
|
+
coexist in ``MIDDLEWARE`` without coupling between them; deployments
|
|
37
|
+
activate the right one via configuration (env var presence).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, get_response):
|
|
41
|
+
self.get_response = get_response
|
|
42
|
+
|
|
43
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
44
|
+
if not self.is_active():
|
|
45
|
+
return self.get_response(request)
|
|
46
|
+
try:
|
|
47
|
+
user = self.authenticate(request)
|
|
48
|
+
except AuthenticationFailed as exc:
|
|
49
|
+
return self._error_response(str(exc), status=401)
|
|
50
|
+
except AuthorizationFailed as exc:
|
|
51
|
+
return self._error_response(str(exc), status=403, prefix="Access denied")
|
|
52
|
+
request.user = user
|
|
53
|
+
setattr(request, REQUEST_FLAG_ATTR, True)
|
|
54
|
+
return self.get_response(request)
|
|
55
|
+
|
|
56
|
+
def is_active(self) -> bool:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def authenticate(self, request: HttpRequest):
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _error_response(
|
|
64
|
+
message: str, status: int, prefix: str = "Authentication failed"
|
|
65
|
+
) -> JsonResponse:
|
|
66
|
+
return JsonResponse(
|
|
67
|
+
{"success": False, "error": f"{prefix}: {message}"},
|
|
68
|
+
status=status,
|
|
69
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Development authentication middleware.
|
|
3
|
+
|
|
4
|
+
Auto-authenticates requests in DEBUG mode for local development.
|
|
5
|
+
This middleware should ONLY be used in development environments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.contrib.auth import get_user_model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DevAuthMiddleware:
|
|
13
|
+
"""
|
|
14
|
+
Middleware that auto-authenticates requests in development mode.
|
|
15
|
+
|
|
16
|
+
If DEBUG=True and the user is not authenticated, this middleware
|
|
17
|
+
will automatically log them in as the dev user (configured via
|
|
18
|
+
DEV_USER_EMAIL setting).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, get_response):
|
|
22
|
+
self.get_response = get_response
|
|
23
|
+
|
|
24
|
+
def __call__(self, request):
|
|
25
|
+
# Only run in DEBUG mode and if user is not already authenticated
|
|
26
|
+
# Check hasattr because this may run before AuthenticationMiddleware
|
|
27
|
+
if settings.DEBUG:
|
|
28
|
+
user = getattr(request, 'user', None)
|
|
29
|
+
if user is None or not user.is_authenticated:
|
|
30
|
+
User = get_user_model()
|
|
31
|
+
dev_email = getattr(settings, 'DEV_USER_EMAIL', 'dev@localhost')
|
|
32
|
+
|
|
33
|
+
# Get or create the dev user
|
|
34
|
+
user, created = User.objects.get_or_create(
|
|
35
|
+
email=dev_email,
|
|
36
|
+
defaults={
|
|
37
|
+
'username': dev_email.split('@')[0],
|
|
38
|
+
'is_staff': True,
|
|
39
|
+
'is_superuser': True,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if created:
|
|
44
|
+
print(f"Created dev user: {dev_email}")
|
|
45
|
+
|
|
46
|
+
request.user = user
|
|
47
|
+
|
|
48
|
+
return self.get_response(request)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Development-only auto-login middleware.
|
|
2
|
+
|
|
3
|
+
Auto-assigns a ``dev_admin`` Django superuser to every request — useful
|
|
4
|
+
for local Docker Compose development where you want CCP4i2 fully usable
|
|
5
|
+
without any token machinery. **Never enable this in production.**
|
|
6
|
+
|
|
7
|
+
Two defensive measures protect against accidental production exposure:
|
|
8
|
+
|
|
9
|
+
1. ``is_active()`` returns False unless ``settings.DEBUG`` is True. Even
|
|
10
|
+
if this middleware is mistakenly listed in a production ``MIDDLEWARE``
|
|
11
|
+
setting, it refuses to activate.
|
|
12
|
+
2. The CCP4i2 settings module only inserts this middleware as the
|
|
13
|
+
*fallback* branch (after LocalSession + AzureAD env-var checks) and
|
|
14
|
+
only when ``DEBUG`` is True. A production-shaped deploy with no auth
|
|
15
|
+
env vars set falls through to *no auth middleware at all*, leaving
|
|
16
|
+
requests as ``AnonymousUser`` — DRF's ``IsAuthenticated`` then 401s
|
|
17
|
+
them. This is strictly safer than the previous "auto-create dev_admin
|
|
18
|
+
on missing config" behaviour.
|
|
19
|
+
|
|
20
|
+
This middleware deliberately does *not* mirror the previous AzureAD
|
|
21
|
+
fallback's permissive default; it is opt-in via ``MIDDLEWARE`` and
|
|
22
|
+
DEBUG-gated.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
from django.conf import settings
|
|
28
|
+
from django.contrib.auth import get_user_model
|
|
29
|
+
from django.http import HttpRequest
|
|
30
|
+
|
|
31
|
+
from .base import BaseAuthMiddleware
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEV_ADMIN_USERNAME = "dev_admin"
|
|
36
|
+
DEV_ADMIN_EMAIL = "dev_admin@localhost"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DevAdminMiddleware(BaseAuthMiddleware):
|
|
40
|
+
"""Auto-assigns a dev_admin superuser when DEBUG is True."""
|
|
41
|
+
|
|
42
|
+
def is_active(self) -> bool:
|
|
43
|
+
return getattr(settings, "DEBUG", False)
|
|
44
|
+
|
|
45
|
+
def authenticate(self, request: HttpRequest):
|
|
46
|
+
User = get_user_model()
|
|
47
|
+
user, created = User.objects.get_or_create(
|
|
48
|
+
username=DEV_ADMIN_USERNAME,
|
|
49
|
+
defaults={
|
|
50
|
+
"email": DEV_ADMIN_EMAIL,
|
|
51
|
+
"first_name": "Dev",
|
|
52
|
+
"last_name": "Admin",
|
|
53
|
+
"is_staff": True,
|
|
54
|
+
"is_superuser": True,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
if created:
|
|
58
|
+
logger.info("Created dev_admin superuser for DEBUG mode")
|
|
59
|
+
# If a previous run created the user without admin flags, top them up
|
|
60
|
+
# so the dev experience stays consistent.
|
|
61
|
+
if not user.is_staff or not user.is_superuser:
|
|
62
|
+
user.is_staff = True
|
|
63
|
+
user.is_superuser = True
|
|
64
|
+
user.save(update_fields=["is_staff", "is_superuser"])
|
|
65
|
+
return user
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Per-launch token middleware for the CCP4i2 desktop app.
|
|
2
|
+
|
|
3
|
+
Validates ``Authorization: Bearer <token>`` against the secret in
|
|
4
|
+
``CCP4I2_LOCAL_SESSION_TOKEN``, set by the Electron main process when
|
|
5
|
+
spawning Django. If the env var is unset, the middleware is a no-op —
|
|
6
|
+
cloud deployments use ``AzureADAuthMiddleware`` instead.
|
|
7
|
+
|
|
8
|
+
Identity: the request is authenticated as the OS user who launched the
|
|
9
|
+
desktop app. The OS-user-derived email is computed by Electron and passed
|
|
10
|
+
via ``CCP4I2_LOCAL_USER_EMAIL`` (Electron sanitises the username so
|
|
11
|
+
domain-joined Windows boxes don't produce malformed emails). If that env
|
|
12
|
+
var is unset, the middleware falls back to a fixed default that is
|
|
13
|
+
well-formed across all platforms.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hmac
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from django.contrib.auth import get_user_model
|
|
20
|
+
from django.http import HttpRequest
|
|
21
|
+
|
|
22
|
+
from ..exceptions import AuthenticationFailed
|
|
23
|
+
from .base import BaseAuthMiddleware
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# RFC 6761 reserves ``.invalid`` for guaranteed-non-resolvable identifiers,
|
|
27
|
+
# so this email is well-formed under Django's EmailValidator and cannot
|
|
28
|
+
# collide with a real account on any platform.
|
|
29
|
+
DEFAULT_DESKTOP_EMAIL = "desktop@ccp4i2.invalid"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LocalSessionAuthMiddleware(BaseAuthMiddleware):
|
|
33
|
+
|
|
34
|
+
def __init__(self, get_response):
|
|
35
|
+
super().__init__(get_response)
|
|
36
|
+
self.expected_token = os.environ.get("CCP4I2_LOCAL_SESSION_TOKEN")
|
|
37
|
+
|
|
38
|
+
def is_active(self) -> bool:
|
|
39
|
+
return self.expected_token is not None
|
|
40
|
+
|
|
41
|
+
def authenticate(self, request: HttpRequest):
|
|
42
|
+
auth = request.META.get("HTTP_AUTHORIZATION", "")
|
|
43
|
+
if not auth.startswith("Bearer "):
|
|
44
|
+
raise AuthenticationFailed("Missing Bearer token")
|
|
45
|
+
provided = auth[len("Bearer "):]
|
|
46
|
+
# Constant-time compare against length-extension / timing attacks.
|
|
47
|
+
# Loopback-only is unlikely to be exploitable, but the precedent
|
|
48
|
+
# is cheap to set for future cloud-side providers.
|
|
49
|
+
if not hmac.compare_digest(provided, self.expected_token):
|
|
50
|
+
raise AuthenticationFailed("Invalid local-session token")
|
|
51
|
+
return self._desktop_user()
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _desktop_user():
|
|
55
|
+
User = get_user_model()
|
|
56
|
+
email = os.environ.get("CCP4I2_LOCAL_USER_EMAIL", DEFAULT_DESKTOP_EMAIL)
|
|
57
|
+
user, _ = User.objects.get_or_create(
|
|
58
|
+
email=email,
|
|
59
|
+
defaults={
|
|
60
|
+
"username": email.split("@")[0],
|
|
61
|
+
"is_staff": True,
|
|
62
|
+
"is_superuser": True,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
return user
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ccp4i2-api
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Shared API contract (auth, api-fetch, request/response types) for CCP4i2 and consumers
|
|
5
|
+
Project-URL: Homepage, https://github.com/ccp4/ccp4i2/tree/django-sliced/packages/ccp4i2-api
|
|
6
|
+
Project-URL: Repository, https://github.com/ccp4/ccp4i2
|
|
7
|
+
Project-URL: Issues, https://github.com/ccp4/ccp4i2/issues
|
|
8
|
+
Author: CCP4i2 contributors
|
|
9
|
+
License: LGPL-3.0-or-later
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: certifi>=2024.0.0
|
|
12
|
+
Requires-Dist: django<5.0,>=4.2
|
|
13
|
+
Requires-Dist: djangorestframework>=3.14
|
|
14
|
+
Requires-Dist: pyjwt[crypto]>=2.10
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest-django; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# `@ccp4/ccp4i2-api` / `ccp4i2-api`
|
|
21
|
+
|
|
22
|
+
Shared API contract for CCP4i2 and consumers (CCP4i2 Compounds, third-party
|
|
23
|
+
integrators) — auth handshake, api-fetch helpers, and request/response
|
|
24
|
+
types. One package, two language artifacts: TypeScript on the client side
|
|
25
|
+
(browser, Electron) and Python on the server side (Django middleware, DRF
|
|
26
|
+
authentication). Both halves agree on the canonical bearer-token format,
|
|
27
|
+
401 response shape, and the typed payloads carried over the authenticated
|
|
28
|
+
channel.
|
|
29
|
+
|
|
30
|
+
## Status
|
|
31
|
+
|
|
32
|
+
**Draft v0 — published to npm + PyPI from the in-tree workspace.** Source
|
|
33
|
+
of truth lives at
|
|
34
|
+
[`packages/ccp4i2-api/`](https://github.com/ccp4/ccp4i2/tree/django-sliced/packages/ccp4i2-api)
|
|
35
|
+
inside the `ccp4/ccp4i2` monorepo on the `django-sliced` branch and stays
|
|
36
|
+
there. The companion `ccp4/ccp4i2-api` GitHub repo (created for the npm
|
|
37
|
+
namespace and external visibility) does not host source.
|
|
38
|
+
|
|
39
|
+
Versions `0.1.0`–`0.3.0` were published under the previous name
|
|
40
|
+
`@ccp4/ccp4i2-auth` / `ccp4i2-auth`; the package was renamed at `0.3.0`
|
|
41
|
+
because its scope had grown beyond auth to cover the broader API contract.
|
|
42
|
+
The old name is unpublished/yanked; consumers should depend on
|
|
43
|
+
`@ccp4/ccp4i2-api` and `ccp4i2-api` from `0.3.0` onward.
|
|
44
|
+
|
|
45
|
+
Versioning follows semver from `0.x.y` onwards. The v0 contract is
|
|
46
|
+
documented in
|
|
47
|
+
[`docs/CCP4I2_SERVICE_CONTRACT.md`](https://github.com/ccp4/ccp4i2/blob/django-sliced/docs/CCP4I2_SERVICE_CONTRACT.md);
|
|
48
|
+
field stability promises take effect from this version.
|
|
49
|
+
|
|
50
|
+
## Layout
|
|
51
|
+
|
|
52
|
+
| Path | Purpose |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `src/` | TypeScript source. Built to `lib/` via `npm run build`. |
|
|
55
|
+
| `lib/` | Built TypeScript output. Generated; gitignored. |
|
|
56
|
+
| `dist/` | Python distribution output (`python -m build`). Generated; gitignored. Kept distinct from `lib/` so `twine upload dist/*` doesn't accidentally pick up TypeScript artefacts. |
|
|
57
|
+
| `ccp4i2_api/` | Python source. Installed editable via `pip install -e .`. |
|
|
58
|
+
| `tests/js/` | TypeScript tests (vitest, when added). |
|
|
59
|
+
| `tests/python/` | Python tests (pytest, when added). |
|
|
60
|
+
|
|
61
|
+
## Consumer wiring
|
|
62
|
+
|
|
63
|
+
In-monorepo consumers can reference this package by local path for fast
|
|
64
|
+
iteration; out-of-monorepo consumers pull the published versions.
|
|
65
|
+
|
|
66
|
+
**TypeScript** — in-monorepo (`client/package.json`):
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@ccp4/ccp4i2-api": "file:../packages/ccp4i2-api"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
(Path depth varies by consumer location.) Out-of-monorepo consumers use the
|
|
75
|
+
published range, e.g. `"@ccp4/ccp4i2-api": "^0.3.0"`.
|
|
76
|
+
|
|
77
|
+
**Python** — in-monorepo (`Docker/server/Dockerfile`, local dev setup):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install -e packages/ccp4i2-api/
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Out-of-monorepo consumers `pip install ccp4i2-api>=0.3`.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# TypeScript
|
|
89
|
+
cd packages/ccp4i2-api
|
|
90
|
+
npm install
|
|
91
|
+
npm run build # produces lib/
|
|
92
|
+
npm run watch # rebuilds on change
|
|
93
|
+
|
|
94
|
+
# Python
|
|
95
|
+
cd packages/ccp4i2-api
|
|
96
|
+
ccp4-python -m pip install -e .
|
|
97
|
+
ccp4-python -c "import ccp4i2_api; print(ccp4i2_api.__version__)"
|
|
98
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ccp4i2_api/__init__.py,sha256=mVW2MQ8KF7wj8BwkSabwR13tZW7EBXIzMRB5cav-yTc,474
|
|
2
|
+
ccp4i2_api/drf.py,sha256=xmhbkxIMKUdQkXUBiKz8iUycGbAF3c5S5wVKBhnN_sM,2329
|
|
3
|
+
ccp4i2_api/exceptions.py,sha256=JnqIyRqDm2qOJAYIitaS1Wvhfvuetu8e5xAeL7MJsvc,664
|
|
4
|
+
ccp4i2_api/middleware/__init__.py,sha256=xhRvvZ7FNF-gTyhAzLO6EWnvqMNsIJL9UxY3kqsvZhc,785
|
|
5
|
+
ccp4i2_api/middleware/azure_ad.py,sha256=rIYl1A92CUAhpyCNAB8UoF2MIL3mI6Xl2R8VNOko4nI,16308
|
|
6
|
+
ccp4i2_api/middleware/base.py,sha256=CwTXbZnzd-aywXyQnTgimmK5bD3nFMcIk45H7MWJL00,2726
|
|
7
|
+
ccp4i2_api/middleware/dev.py,sha256=cOJuFNnPOnYQyJA4olDBlxD5tRPaH7xObI-5-8xAp-Q,1590
|
|
8
|
+
ccp4i2_api/middleware/dev_admin.py,sha256=dP5qKgN5mSpQOtOaVMw7AVVHjsf3CU01SO2oxz22mmg,2444
|
|
9
|
+
ccp4i2_api/middleware/local_session.py,sha256=ULTvKtMC3Db9bN3R0aOc6FbKn3Dfa6akLEV7XMg-lDQ,2497
|
|
10
|
+
ccp4i2_api-0.3.0.dist-info/METADATA,sha256=vb95GqwsU2VIG95YbJeI_3EuNB1pY5ZmAJRJlLeIRNk,3660
|
|
11
|
+
ccp4i2_api-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
ccp4i2_api-0.3.0.dist-info/RECORD,,
|