runtm-api 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.
runtm_api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Runtm API: FastAPI control plane for deployment management."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ """Authentication module."""
2
+
3
+ from runtm_api.auth.keys import (
4
+ PREFIX_LENGTH,
5
+ generate_api_key,
6
+ hash_key,
7
+ validate_token_format,
8
+ verify_key,
9
+ )
10
+ from runtm_api.auth.token import (
11
+ RequireAuth,
12
+ extract_bearer_token,
13
+ get_auth_context,
14
+ require_scope,
15
+ )
16
+
17
+ __all__ = [
18
+ # Token authentication
19
+ "RequireAuth",
20
+ "extract_bearer_token",
21
+ "get_auth_context",
22
+ "require_scope",
23
+ # Key generation and verification
24
+ "PREFIX_LENGTH",
25
+ "generate_api_key",
26
+ "hash_key",
27
+ "validate_token_format",
28
+ "verify_key",
29
+ ]
runtm_api/auth/keys.py ADDED
@@ -0,0 +1,126 @@
1
+ """API key generation and verification with versioned HMAC.
2
+
3
+ This module provides secure API key handling with:
4
+ - HMAC-SHA256 hashing with server-side pepper (not plain SHA256)
5
+ - Versioned peppers for rotation without breaking existing keys
6
+ - 16-character prefix for near-O(1) database lookup
7
+ - Constant-time comparison to prevent timing attacks
8
+
9
+ Pepper Rotation Workflow:
10
+ 1. Add TOKEN_PEPPER_V2 to environment
11
+ 2. Update CURRENT_PEPPER_VERSION to 2 for new keys
12
+ 3. Set PEPPER_MIGRATION_VERSIONS="1,2" during transition
13
+ 4. Verification tries stored version first, then migration window versions
14
+ 5. After all keys rotated: remove TOKEN_PEPPER_V1, clear migration versions
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import hmac
21
+ import secrets
22
+
23
+ # Prefix length: 16 chars reduces collisions, makes lookup near-O(1)
24
+ # Format: "runtm_" (6 chars) + 10 chars of token = 16 chars total
25
+ PREFIX_LENGTH = 16
26
+
27
+
28
+ def generate_api_key() -> tuple[str, str]:
29
+ """Generate a new API key.
30
+
31
+ The raw key is shown to the user exactly once at creation.
32
+ Only the prefix and hash are stored in the database.
33
+
34
+ Returns:
35
+ Tuple of (raw_token, prefix):
36
+ - raw_token: Full key like "runtm_abc123..." (shown once, never stored)
37
+ - prefix: First 16 chars for database lookup
38
+ """
39
+ # Generate secure random token with runtm_ prefix
40
+ random_part = secrets.token_urlsafe(32)
41
+ raw_token = f"runtm_{random_part}"
42
+ prefix = raw_token[:PREFIX_LENGTH]
43
+ return raw_token, prefix
44
+
45
+
46
+ def hash_key(raw_token: str, pepper: str) -> str:
47
+ """HMAC-SHA256 hash with server pepper.
48
+
49
+ Uses HMAC instead of plain SHA256 for:
50
+ - Resistance to offline brute-force if database leaks
51
+ - Ability to rotate pepper without rehashing all keys
52
+
53
+ Args:
54
+ raw_token: The raw API key from user
55
+ pepper: Server-side secret (from environment/KMS)
56
+
57
+ Returns:
58
+ Hex-encoded HMAC-SHA256 hash (64 characters)
59
+ """
60
+ return hmac.new(
61
+ pepper.encode("utf-8"),
62
+ raw_token.encode("utf-8"),
63
+ hashlib.sha256,
64
+ ).hexdigest()
65
+
66
+
67
+ def verify_key(
68
+ raw_token: str,
69
+ stored_hash: str,
70
+ stored_pepper_version: int,
71
+ peppers: dict[int, str],
72
+ migration_window_versions: set[int] | None = None,
73
+ ) -> bool:
74
+ """Verify an API key with pepper versioning support.
75
+
76
+ Uses constant-time comparison to prevent timing attacks.
77
+
78
+ Args:
79
+ raw_token: The raw API key from the request
80
+ stored_hash: Hash stored in database for this key
81
+ stored_pepper_version: Pepper version used when key was created
82
+ peppers: Map of version number -> pepper value
83
+ migration_window_versions: Optional set of versions to try during rotation
84
+
85
+ Returns:
86
+ True if key is valid, False otherwise
87
+
88
+ Security Notes:
89
+ - Always tries stored version first (most common case)
90
+ - Migration window allows rotation without breaking existing keys
91
+ - Uses hmac.compare_digest for constant-time comparison
92
+ """
93
+ # Try stored version first (most common case, best performance)
94
+ if stored_pepper_version in peppers:
95
+ computed = hash_key(raw_token, peppers[stored_pepper_version])
96
+ if hmac.compare_digest(computed, stored_hash):
97
+ return True
98
+
99
+ # During migration window, try other versions
100
+ # This handles the case where a key's pepper_version doesn't match
101
+ # the actual pepper used (shouldn't happen, but defensive)
102
+ if migration_window_versions:
103
+ for version in migration_window_versions:
104
+ if version != stored_pepper_version and version in peppers:
105
+ computed = hash_key(raw_token, peppers[version])
106
+ if hmac.compare_digest(computed, stored_hash):
107
+ return True
108
+
109
+ return False
110
+
111
+
112
+ def validate_token_format(token: str) -> bool:
113
+ """Validate that a token has the expected format.
114
+
115
+ Args:
116
+ token: Token string to validate
117
+
118
+ Returns:
119
+ True if token format is valid (starts with "runtm_" and has sufficient length)
120
+ """
121
+ if not token:
122
+ return False
123
+ if not token.startswith("runtm_"):
124
+ return False
125
+ # Minimum length: "runtm_" (6) + some random chars
126
+ return not len(token) < 20
@@ -0,0 +1,486 @@
1
+ """Token-based authentication for Runtm API.
2
+
3
+ Supports two modes:
4
+ - SINGLE_TENANT: Simple static token from environment (for self-hosting)
5
+ - MULTI_TENANT: API keys stored in database with versioned HMAC hashing
6
+
7
+ Multi-tenant mode features:
8
+ - HMAC-SHA256 with server-side pepper (not plain SHA256)
9
+ - Versioned peppers for rotation without breaking existing keys
10
+ - 16-character prefix for near-O(1) database lookup
11
+ - Throttled last_used_at updates (max every 5 minutes)
12
+ - Rate limiting on failed auth attempts per IP (fail-closed in production)
13
+ - Client IP tracking for audit
14
+ - Structured audit logging for security monitoring
15
+
16
+ Security Notes:
17
+ - Rate limiting prevents prefix enumeration attacks
18
+ - Constant-time comparison used throughout
19
+ - Failed auths return identical errors (no enumeration)
20
+ - All auth events logged for security monitoring
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hmac
26
+ import json
27
+ import logging
28
+ from datetime import datetime, timezone
29
+
30
+ from fastapi import Depends, HTTPException, Request, status
31
+ from sqlalchemy.orm import Session
32
+
33
+ from runtm_api.auth.keys import PREFIX_LENGTH, hash_key, validate_token_format, verify_key
34
+ from runtm_api.core.config import Settings, get_settings
35
+ from runtm_api.db.models import ApiKey
36
+ from runtm_api.db.session import get_db
37
+ from runtm_shared.errors import InvalidTokenError
38
+ from runtm_shared.types import ApiKeyScope, AuthContext, AuthMode
39
+
40
+ # Throttle last_used_at updates to avoid write amplification
41
+ # Only update if more than 5 minutes since last update
42
+ LAST_USED_UPDATE_THRESHOLD_SECONDS = 300
43
+
44
+ # Structured logger for auth events
45
+ _audit_logger = logging.getLogger("runtm.auth.audit")
46
+
47
+
48
+ def _log_auth_event(
49
+ event: str,
50
+ ip: str,
51
+ success: bool,
52
+ reason: str = "",
53
+ identifier: str = "",
54
+ tenant_id: str = "",
55
+ ) -> None:
56
+ """Log auth events for security monitoring.
57
+
58
+ SECURITY: Logs structured auth events for:
59
+ - Security monitoring and alerting
60
+ - Incident investigation and forensics
61
+ - Compliance and audit trails
62
+
63
+ Never logs full tokens - only the first 16 chars (identifier) which
64
+ is the same prefix stored in the database.
65
+
66
+ Args:
67
+ event: Event type (e.g., "auth_success", "auth_failed", "rate_limited")
68
+ ip: Client IP address
69
+ success: Whether authentication succeeded
70
+ reason: Failure reason if applicable
71
+ identifier: Token prefix (first 16 chars) or username
72
+ tenant_id: Tenant ID if known (on success)
73
+ """
74
+ # Build structured log entry as JSON
75
+ log_entry = {
76
+ "event": event,
77
+ "ip": ip,
78
+ "success": success,
79
+ "timestamp": datetime.now(timezone.utc).isoformat(),
80
+ }
81
+
82
+ if reason:
83
+ log_entry["reason"] = reason
84
+ if identifier:
85
+ # Never log more than 16 chars of token/identifier
86
+ log_entry["identifier"] = identifier[:16]
87
+ if tenant_id:
88
+ log_entry["tenant_id"] = tenant_id
89
+
90
+ # Log as JSON for easy parsing by log aggregators
91
+ _audit_logger.info(json.dumps(log_entry))
92
+
93
+
94
+ def _get_client_ip(request: Request) -> str:
95
+ """Extract client IP from request, handling proxies.
96
+
97
+ Note: This is a basic implementation. For production with untrusted
98
+ networks, use the trusted proxy middleware from runtm_api.middleware.proxy.
99
+
100
+ Args:
101
+ request: FastAPI request
102
+
103
+ Returns:
104
+ Client IP address
105
+ """
106
+ # Check common proxy headers
107
+ forwarded_for = request.headers.get("X-Forwarded-For", "")
108
+ if forwarded_for:
109
+ # Take the first IP (original client)
110
+ return forwarded_for.split(",")[0].strip()
111
+
112
+ # Fly.io specific header
113
+ fly_client_ip = request.headers.get("Fly-Client-IP")
114
+ if fly_client_ip:
115
+ return fly_client_ip
116
+
117
+ # Fall back to direct connection
118
+ return request.client.host if request.client else "unknown"
119
+
120
+
121
+ def _check_auth_rate_limit(ip: str, identifier: str = "") -> bool:
122
+ """Check auth rate limit using Redis. Fails CLOSED in production.
123
+
124
+ SECURITY: This function fails closed in production - if Redis is
125
+ unavailable, authentication requests are rejected with 503.
126
+ In debug mode, it fails open to allow local development.
127
+
128
+ Args:
129
+ ip: Client IP address
130
+ identifier: Token prefix for rate limiting (first 16 chars)
131
+
132
+ Returns:
133
+ True if allowed, False if rate limited
134
+
135
+ Raises:
136
+ HTTPException: 503 if rate limiter unavailable in production
137
+ """
138
+ from runtm_api.services.rate_limit import get_rate_limiter
139
+
140
+ settings = get_settings()
141
+ limiter = get_rate_limiter()
142
+
143
+ if limiter is None:
144
+ if settings.debug:
145
+ # Dev mode: allow if Redis unavailable
146
+ return True
147
+ # Production: fail closed - no Redis = no auth
148
+ raise HTTPException(
149
+ status_code=503,
150
+ detail={"error": "Rate limiter unavailable. Try again later."},
151
+ )
152
+
153
+ allowed, _remaining, _reset_at = limiter.check_auth_rate_limit(ip, identifier)
154
+ return allowed
155
+
156
+
157
+ def extract_bearer_token(request: Request) -> str | None:
158
+ """Extract Bearer token from Authorization header.
159
+
160
+ Args:
161
+ request: FastAPI request object
162
+
163
+ Returns:
164
+ Token string if found, None otherwise
165
+ """
166
+ auth_header = request.headers.get("Authorization")
167
+ if not auth_header:
168
+ return None
169
+
170
+ parts = auth_header.split()
171
+ if len(parts) != 2 or parts[0].lower() != "bearer":
172
+ return None
173
+
174
+ return parts[1]
175
+
176
+
177
+ async def get_auth_context(
178
+ request: Request,
179
+ db: Session = Depends(get_db),
180
+ settings: Settings = Depends(get_settings),
181
+ ) -> AuthContext:
182
+ """Validate request authentication and return AuthContext.
183
+
184
+ In single-tenant mode, validates against RUNTM_API_SECRET.
185
+ In multi-tenant mode, looks up API key by prefix and verifies with HMAC.
186
+
187
+ Args:
188
+ request: FastAPI request object
189
+ db: Database session
190
+ settings: Application settings
191
+
192
+ Returns:
193
+ AuthContext with validated token info
194
+
195
+ Raises:
196
+ HTTPException: If authentication fails
197
+ """
198
+ token = extract_bearer_token(request)
199
+
200
+ if not token:
201
+ raise HTTPException(
202
+ status_code=status.HTTP_401_UNAUTHORIZED,
203
+ detail=InvalidTokenError().to_dict(),
204
+ headers={"WWW-Authenticate": "Bearer"},
205
+ )
206
+
207
+ if settings.auth_mode == AuthMode.SINGLE_TENANT:
208
+ return _authenticate_single_tenant(token, settings, request)
209
+
210
+ if settings.auth_mode == AuthMode.MULTI_TENANT:
211
+ return await _authenticate_multi_tenant(token, db, settings, request)
212
+
213
+ raise HTTPException(
214
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
215
+ detail={"error": f"Unsupported auth mode: {settings.auth_mode}"},
216
+ )
217
+
218
+
219
+ def _authenticate_single_tenant(token: str, settings: Settings, request: Request) -> AuthContext:
220
+ """Authenticate in single-tenant mode using static token.
221
+
222
+ SECURITY: In production, RUNTM_API_SECRET must be set. The insecure
223
+ bypass (accept any token) requires BOTH debug=True AND
224
+ allow_insecure_dev_auth=True to prevent accidental exposure.
225
+
226
+ Args:
227
+ token: Bearer token from request
228
+ settings: Application settings
229
+
230
+ Returns:
231
+ AuthContext for the default tenant with full permissions
232
+
233
+ Raises:
234
+ HTTPException: If token is invalid or not configured
235
+ """
236
+ import logging
237
+
238
+ if not settings.api_secret:
239
+ # SECURITY: Only allow bypass if BOTH flags are explicitly set
240
+ if settings.debug and settings.allow_insecure_dev_auth:
241
+ logging.warning(
242
+ "SECURITY WARNING: Running with ALLOW_INSECURE_DEV_AUTH=true. "
243
+ "Any token will be accepted. DO NOT USE IN PRODUCTION."
244
+ )
245
+ return AuthContext(
246
+ token=token,
247
+ tenant_id="default",
248
+ principal_id="default",
249
+ scopes={
250
+ ApiKeyScope.READ.value,
251
+ ApiKeyScope.DEPLOY.value,
252
+ ApiKeyScope.DELETE.value,
253
+ },
254
+ )
255
+
256
+ # In all other cases, fail with clear error
257
+ raise HTTPException(
258
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
259
+ detail={
260
+ "error": "RUNTM_API_SECRET not configured",
261
+ "hint": "Set RUNTM_API_SECRET environment variable",
262
+ },
263
+ )
264
+
265
+ # Use constant-time comparison to prevent timing attacks
266
+ if not hmac.compare_digest(token, settings.api_secret):
267
+ raise HTTPException(
268
+ status_code=status.HTTP_401_UNAUTHORIZED,
269
+ detail=InvalidTokenError().to_dict(),
270
+ headers={"WWW-Authenticate": "Bearer"},
271
+ )
272
+
273
+ # Determine tenant_id and principal_id
274
+ tenant_id = "default"
275
+ principal_id = "default"
276
+
277
+ if settings.trust_tenant_header:
278
+ # Trust headers from authenticated internal proxy
279
+ header_tenant_id = request.headers.get("X-Tenant-Id")
280
+ header_user_id = request.headers.get("X-User-Id")
281
+ if header_tenant_id:
282
+ tenant_id = header_tenant_id
283
+ if header_user_id:
284
+ principal_id = header_user_id
285
+
286
+ return AuthContext(
287
+ token=token,
288
+ tenant_id=tenant_id,
289
+ principal_id=principal_id,
290
+ scopes={
291
+ ApiKeyScope.READ.value,
292
+ ApiKeyScope.DEPLOY.value,
293
+ ApiKeyScope.DELETE.value,
294
+ },
295
+ )
296
+
297
+
298
+ async def _authenticate_multi_tenant(
299
+ token: str,
300
+ db: Session,
301
+ settings: Settings,
302
+ request: Request,
303
+ ) -> AuthContext:
304
+ """Authenticate in multi-tenant mode using API keys.
305
+
306
+ Lookup flow:
307
+ 1. Check rate limit for client IP + token prefix
308
+ 2. Validate token format (starts with "runtm_")
309
+ 3. Extract prefix for near-O(1) DB lookup
310
+ 4. Query candidates by prefix (non-revoked keys)
311
+ 5. Verify HMAC hash with versioned peppers (constant-time)
312
+ 6. Check expiration
313
+ 7. Update last_used_at and last_used_ip (throttled)
314
+
315
+ Security:
316
+ - Rate limiting prevents prefix enumeration attacks (uses Redis, fail-closed)
317
+ - Constant-time comparison used even when no candidates
318
+ - Identical error responses for all auth failures
319
+
320
+ Args:
321
+ token: Bearer token from request
322
+ db: Database session
323
+ settings: Application settings
324
+ request: FastAPI request for IP extraction
325
+
326
+ Returns:
327
+ AuthContext with tenant, principal, and scopes
328
+
329
+ Raises:
330
+ HTTPException: If token is invalid, expired, or revoked
331
+ """
332
+ client_ip = _get_client_ip(request)
333
+
334
+ # Extract identifier from token prefix for rate limiting
335
+ # (We don't have username at this point, but prefix is unique enough)
336
+ identifier = token[:16] if len(token) >= 16 else ""
337
+
338
+ # Check rate limit first (fail-closed in production)
339
+ if not _check_auth_rate_limit(client_ip, identifier):
340
+ _log_auth_event(
341
+ "rate_limited",
342
+ client_ip,
343
+ False,
344
+ reason="too_many_attempts",
345
+ identifier=identifier,
346
+ )
347
+ raise HTTPException(
348
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
349
+ detail={"error": "Too many authentication attempts. Try again later."},
350
+ )
351
+
352
+ # Helper to raise auth error with logging
353
+ def _auth_failed(reason: str = "invalid_token") -> None:
354
+ _log_auth_event(
355
+ "auth_failed",
356
+ client_ip,
357
+ False,
358
+ reason=reason,
359
+ identifier=identifier,
360
+ )
361
+ raise HTTPException(
362
+ status_code=status.HTTP_401_UNAUTHORIZED,
363
+ detail=InvalidTokenError().to_dict(),
364
+ headers={"WWW-Authenticate": "Bearer"},
365
+ )
366
+
367
+ # Validate token format
368
+ if not validate_token_format(token):
369
+ _auth_failed("invalid_format")
370
+
371
+ # Check pepper configuration
372
+ if not settings.peppers:
373
+ raise HTTPException(
374
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
375
+ detail={"error": "Token pepper not configured for multi-tenant mode"},
376
+ )
377
+
378
+ # Extract prefix for lookup
379
+ prefix = token[:PREFIX_LENGTH]
380
+
381
+ # Query candidates by prefix (only non-revoked)
382
+ candidates = (
383
+ db.query(ApiKey)
384
+ .filter(
385
+ ApiKey.key_prefix == prefix,
386
+ ApiKey.is_revoked == False, # noqa: E712
387
+ )
388
+ .all()
389
+ )
390
+
391
+ if not candidates:
392
+ # No matching keys - perform constant-time fake check to prevent timing oracle
393
+ # This ensures the response time is similar whether or not the prefix exists
394
+ fake_hash = hash_key(token, list(settings.peppers.values())[0])
395
+ hmac.compare_digest(fake_hash, "0" * 64)
396
+ _auth_failed("no_matching_key")
397
+
398
+ # Verify hash with versioned HMAC
399
+ api_key = None
400
+ for candidate in candidates:
401
+ if verify_key(
402
+ raw_token=token,
403
+ stored_hash=candidate.key_hash,
404
+ stored_pepper_version=candidate.pepper_version,
405
+ peppers=settings.peppers,
406
+ migration_window_versions=settings.migration_versions,
407
+ ):
408
+ api_key = candidate
409
+ break
410
+
411
+ if not api_key:
412
+ _auth_failed("hash_mismatch")
413
+
414
+ # Check expiration
415
+ now = datetime.now(timezone.utc)
416
+ if api_key.expires_at and api_key.expires_at < now:
417
+ _auth_failed("token_expired")
418
+
419
+ # Throttled last_used_at update (avoid write amplification)
420
+ should_update = False
421
+ if api_key.last_used_at is None:
422
+ should_update = True
423
+ else:
424
+ # Ensure both datetimes are timezone-aware for comparison
425
+ last_used = api_key.last_used_at
426
+ if last_used.tzinfo is None:
427
+ last_used = last_used.replace(tzinfo=timezone.utc)
428
+
429
+ seconds_since_last_use = (now - last_used).total_seconds()
430
+ if seconds_since_last_use > LAST_USED_UPDATE_THRESHOLD_SECONDS:
431
+ should_update = True
432
+
433
+ if should_update:
434
+ api_key.last_used_at = now
435
+ # Track client IP for audit
436
+ if hasattr(api_key, "last_used_ip"):
437
+ api_key.last_used_ip = client_ip
438
+ db.commit()
439
+
440
+ # Log successful authentication
441
+ _log_auth_event(
442
+ "auth_success",
443
+ client_ip,
444
+ True,
445
+ identifier=identifier,
446
+ tenant_id=api_key.tenant_id,
447
+ )
448
+
449
+ return AuthContext(
450
+ token=token,
451
+ tenant_id=api_key.tenant_id,
452
+ principal_id=api_key.principal_id,
453
+ api_key_id=str(api_key.id),
454
+ scopes=set(api_key.scopes) if api_key.scopes else set(),
455
+ )
456
+
457
+
458
+ def require_scope(scope: ApiKeyScope):
459
+ """Dependency factory to enforce scope on a route.
460
+
461
+ Usage:
462
+ @router.post("", dependencies=[require_scope(ApiKeyScope.DEPLOY)])
463
+ async def create_deployment(...):
464
+ ...
465
+
466
+ Args:
467
+ scope: Required scope for the route
468
+
469
+ Returns:
470
+ FastAPI dependency that checks the scope
471
+ """
472
+ from runtm_shared.types import has_scope
473
+
474
+ async def checker(auth: AuthContext = Depends(get_auth_context)) -> AuthContext:
475
+ if not has_scope(auth.scopes, scope):
476
+ raise HTTPException(
477
+ status_code=status.HTTP_403_FORBIDDEN,
478
+ detail={"error": f"Missing required scope: {scope.value}"},
479
+ )
480
+ return auth
481
+
482
+ return Depends(checker)
483
+
484
+
485
+ # Dependency for requiring authentication (any valid token)
486
+ RequireAuth = Depends(get_auth_context)
@@ -0,0 +1 @@
1
+ """Core configuration and utilities."""