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 +3 -0
- runtm_api/auth/__init__.py +29 -0
- runtm_api/auth/keys.py +126 -0
- runtm_api/auth/token.py +486 -0
- runtm_api/core/__init__.py +1 -0
- runtm_api/core/config.py +277 -0
- runtm_api/db/__init__.py +44 -0
- runtm_api/db/models.py +604 -0
- runtm_api/db/repository.py +281 -0
- runtm_api/db/session.py +82 -0
- runtm_api/main.py +118 -0
- runtm_api/middleware/__init__.py +17 -0
- runtm_api/middleware/proxy.py +207 -0
- runtm_api/routes/__init__.py +13 -0
- runtm_api/routes/deployments.py +1528 -0
- runtm_api/routes/health.py +29 -0
- runtm_api/routes/me.py +90 -0
- runtm_api/routes/telemetry.py +379 -0
- runtm_api/services/__init__.py +23 -0
- runtm_api/services/idempotency.py +97 -0
- runtm_api/services/policy.py +308 -0
- runtm_api/services/queue.py +73 -0
- runtm_api/services/rate_limit.py +325 -0
- runtm_api/services/telemetry.py +640 -0
- runtm_api/services/usage.py +221 -0
- runtm_api/telemetry.py +144 -0
- runtm_api-0.1.0.dist-info/METADATA +126 -0
- runtm_api-0.1.0.dist-info/RECORD +30 -0
- runtm_api-0.1.0.dist-info/WHEEL +4 -0
- runtm_api-0.1.0.dist-info/licenses/LICENSE +37 -0
runtm_api/__init__.py
ADDED
|
@@ -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
|
runtm_api/auth/token.py
ADDED
|
@@ -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."""
|