codeframe-ai 0.9.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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Authentication dependencies for route handlers.
|
|
2
|
+
|
|
3
|
+
Supports dual authentication:
|
|
4
|
+
- JWT Bearer tokens (existing FastAPI Users integration)
|
|
5
|
+
- API keys via X-API-Key header (new for programmatic access)
|
|
6
|
+
|
|
7
|
+
API keys use scope-based permissions (read, write, admin).
|
|
8
|
+
JWT tokens get full permissions for backward compatibility.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from typing import Callable, Dict, Optional, Any
|
|
15
|
+
|
|
16
|
+
from fastapi import Depends, HTTPException, Request, Security, status
|
|
17
|
+
from fastapi.security import APIKeyHeader, HTTPBearer, HTTPAuthorizationCredentials
|
|
18
|
+
|
|
19
|
+
from codeframe.auth.models import User
|
|
20
|
+
from codeframe.auth.api_keys import (
|
|
21
|
+
extract_prefix,
|
|
22
|
+
verify_api_key,
|
|
23
|
+
SCOPE_READ,
|
|
24
|
+
SCOPE_WRITE,
|
|
25
|
+
SCOPE_ADMIN,
|
|
26
|
+
)
|
|
27
|
+
from codeframe.auth.scopes import has_scope
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Security schemes
|
|
32
|
+
security = HTTPBearer(auto_error=False)
|
|
33
|
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
34
|
+
|
|
35
|
+
# Truthy/falsy values for CODEFRAME_AUTH_REQUIRED (case-insensitive).
|
|
36
|
+
_AUTH_FALSY = {"0", "false", "no", "off"}
|
|
37
|
+
|
|
38
|
+
# Routes allowed to authenticate via a ?token=<JWT> query parameter. Browser
|
|
39
|
+
# EventSource (SSE) cannot send an Authorization header, so these streaming
|
|
40
|
+
# routes accept the token in the URL — the same trade-off the WebSocket
|
|
41
|
+
# routes already make. Keep this list tight: query-string credentials can
|
|
42
|
+
# leak via proxy/access logs and browser history, so the fallback must NOT
|
|
43
|
+
# apply to the rest of the API (codex review P2, issue #336).
|
|
44
|
+
_QUERY_TOKEN_PATHS = (
|
|
45
|
+
re.compile(r"^/api/v2/tasks/[^/]+/stream$"), # task event stream (SSE)
|
|
46
|
+
re.compile(r"^/api/v2/prd/stress-test$"), # PRD stress-test stream (SSE)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _query_token_allowed(path: str) -> bool:
|
|
51
|
+
"""Whether this request path may authenticate via ?token= (SSE only)."""
|
|
52
|
+
return any(pattern.match(path) for pattern in _QUERY_TOKEN_PATHS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def auth_required() -> bool:
|
|
56
|
+
"""Whether authentication is enforced, read from the environment.
|
|
57
|
+
|
|
58
|
+
Controlled by ``CODEFRAME_AUTH_REQUIRED`` (default ON / secure by default).
|
|
59
|
+
Read at request time so tests can monkeypatch the value per call.
|
|
60
|
+
|
|
61
|
+
Falsy values (case-insensitive): ``0``, ``false``, ``no``, ``off``.
|
|
62
|
+
Anything else (including unset) is treated as enabled.
|
|
63
|
+
"""
|
|
64
|
+
value = os.getenv("CODEFRAME_AUTH_REQUIRED")
|
|
65
|
+
if value is None:
|
|
66
|
+
return True
|
|
67
|
+
return value.strip().lower() not in _AUTH_FALSY
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def get_current_user(
|
|
71
|
+
request: Request,
|
|
72
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
73
|
+
) -> User:
|
|
74
|
+
"""Get currently authenticated user.
|
|
75
|
+
|
|
76
|
+
Requires a valid JWT, supplied as an ``Authorization: Bearer`` header.
|
|
77
|
+
On the allowlisted SSE routes only (``_QUERY_TOKEN_PATHS``), a
|
|
78
|
+
``?token=<JWT>`` query parameter is accepted when no header is present
|
|
79
|
+
(browser EventSource cannot send headers; mirrors the WebSocket
|
|
80
|
+
auth pattern).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
request: FastAPI request object
|
|
84
|
+
credentials: Bearer token from Authorization header (optional)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Authenticated user
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
HTTPException: 401 if authentication not provided or invalid
|
|
91
|
+
"""
|
|
92
|
+
# Resolve the bearer token from the Authorization header. Only the
|
|
93
|
+
# allowlisted SSE routes may fall back to a ?token= query parameter
|
|
94
|
+
# (EventSource cannot send headers); everywhere else query-string
|
|
95
|
+
# credentials are rejected to keep them out of logs/history.
|
|
96
|
+
token: Optional[str] = None
|
|
97
|
+
if credentials and getattr(credentials, "credentials", None):
|
|
98
|
+
token = credentials.credentials
|
|
99
|
+
elif request is not None and _query_token_allowed(request.url.path):
|
|
100
|
+
token = request.query_params.get("token")
|
|
101
|
+
|
|
102
|
+
if not token:
|
|
103
|
+
raise HTTPException(
|
|
104
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
105
|
+
detail="Not authenticated",
|
|
106
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Validate JWT token
|
|
110
|
+
try:
|
|
111
|
+
import jwt as pyjwt
|
|
112
|
+
from codeframe.auth.manager import (
|
|
113
|
+
SECRET,
|
|
114
|
+
JWT_ALGORITHM,
|
|
115
|
+
JWT_AUDIENCE,
|
|
116
|
+
get_async_session_maker,
|
|
117
|
+
)
|
|
118
|
+
from sqlalchemy import select
|
|
119
|
+
|
|
120
|
+
# Decode JWT token directly using PyJWT
|
|
121
|
+
# Note: We use direct PyJWT decoding instead of JWTStrategy.read_token()
|
|
122
|
+
# because read_token() requires a user_manager instance, which would
|
|
123
|
+
# create a circular dependency. The JWT constants are centralized in
|
|
124
|
+
# auth.manager to ensure consistency with the JWTStrategy configuration.
|
|
125
|
+
try:
|
|
126
|
+
payload = pyjwt.decode(
|
|
127
|
+
token,
|
|
128
|
+
SECRET,
|
|
129
|
+
algorithms=[JWT_ALGORITHM],
|
|
130
|
+
audience=JWT_AUDIENCE,
|
|
131
|
+
)
|
|
132
|
+
user_id_str = payload.get("sub")
|
|
133
|
+
if not user_id_str:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
136
|
+
detail="Invalid token: missing subject",
|
|
137
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
138
|
+
)
|
|
139
|
+
user_id = int(user_id_str)
|
|
140
|
+
except pyjwt.ExpiredSignatureError:
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
143
|
+
detail="Token expired",
|
|
144
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
145
|
+
)
|
|
146
|
+
except (pyjwt.InvalidTokenError, ValueError) as e:
|
|
147
|
+
logger.debug(f"JWT decode error: {e}")
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
150
|
+
detail="Invalid token",
|
|
151
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Get user from database
|
|
155
|
+
async_session_maker = get_async_session_maker()
|
|
156
|
+
async with async_session_maker() as session:
|
|
157
|
+
result = await session.execute(select(User).where(User.id == user_id))
|
|
158
|
+
user = result.scalar_one_or_none()
|
|
159
|
+
|
|
160
|
+
if user is None:
|
|
161
|
+
raise HTTPException(
|
|
162
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
163
|
+
detail="User not found",
|
|
164
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if not user.is_active:
|
|
168
|
+
raise HTTPException(
|
|
169
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
170
|
+
detail="User is inactive",
|
|
171
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return user
|
|
175
|
+
|
|
176
|
+
except HTTPException:
|
|
177
|
+
raise
|
|
178
|
+
except Exception as e:
|
|
179
|
+
# Log full error server-side for debugging
|
|
180
|
+
logger.error(f"Authentication error: {str(e)}", exc_info=True)
|
|
181
|
+
# Return generic message to client (avoid leaking implementation details)
|
|
182
|
+
raise HTTPException(
|
|
183
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
184
|
+
detail="Authentication failed",
|
|
185
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def get_current_user_optional(
|
|
190
|
+
request: Request,
|
|
191
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
192
|
+
) -> Optional[User]:
|
|
193
|
+
"""Get currently authenticated user, or None if not authenticated.
|
|
194
|
+
|
|
195
|
+
Non-raising version for endpoints that optionally use authentication.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
return await get_current_user(request, credentials)
|
|
199
|
+
except HTTPException:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# =============================================================================
|
|
204
|
+
# API Key Authentication
|
|
205
|
+
# =============================================================================
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def get_api_key_auth(
|
|
209
|
+
api_key: Optional[str] = Security(api_key_header),
|
|
210
|
+
request: Request = None,
|
|
211
|
+
) -> Optional[Dict[str, Any]]:
|
|
212
|
+
"""Extract and validate API key from X-API-Key header.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
api_key: API key from header (auto-extracted by FastAPI Security)
|
|
216
|
+
request: FastAPI request object (for accessing db via state)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Auth dict if valid API key, None otherwise.
|
|
220
|
+
Dict contains: type, user_id, scopes, key_id
|
|
221
|
+
"""
|
|
222
|
+
if not api_key:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
# Get database from app state (singleton) or request state
|
|
227
|
+
db = getattr(request.app.state, "db", None)
|
|
228
|
+
if db is None:
|
|
229
|
+
db = getattr(request.state, "db", None)
|
|
230
|
+
if db is None:
|
|
231
|
+
# Fallback: create database connection
|
|
232
|
+
logger.warning("No db in app.state, creating fallback connection for API key auth")
|
|
233
|
+
import os
|
|
234
|
+
from codeframe.platform_store.database import Database
|
|
235
|
+
|
|
236
|
+
db_path = os.getenv(
|
|
237
|
+
"DATABASE_PATH",
|
|
238
|
+
os.path.join(os.getcwd(), ".codeframe", "state.db")
|
|
239
|
+
)
|
|
240
|
+
db = Database(db_path)
|
|
241
|
+
db.initialize()
|
|
242
|
+
# Store on request state so it can be cleaned up by middleware
|
|
243
|
+
if request is not None:
|
|
244
|
+
request.state.db = db
|
|
245
|
+
|
|
246
|
+
# Extract prefix and look up key
|
|
247
|
+
try:
|
|
248
|
+
prefix = extract_prefix(api_key)
|
|
249
|
+
except ValueError:
|
|
250
|
+
logger.warning("API key auth failed: invalid key format")
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
key_record = db.api_keys.get_by_prefix(prefix)
|
|
254
|
+
if key_record is None:
|
|
255
|
+
logger.warning(f"API key auth failed: key not found (prefix: {prefix[:4]}...)")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
# Verify the full key against stored hash
|
|
259
|
+
if not verify_api_key(api_key, key_record["key_hash"]):
|
|
260
|
+
logger.warning(f"API key auth failed: verification failed (prefix: {prefix[:4]}...)")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
# Update last used timestamp (fire and forget)
|
|
264
|
+
try:
|
|
265
|
+
db.api_keys.update_last_used(key_record["id"])
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.warning(f"Failed to update last_used_at: {e}")
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"type": "api_key",
|
|
271
|
+
"user_id": key_record["user_id"],
|
|
272
|
+
"scopes": key_record["scopes"],
|
|
273
|
+
"key_id": key_record["id"],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.debug(f"API key authentication error: {e}")
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def require_auth(
|
|
282
|
+
api_key_auth: Optional[Dict[str, Any]] = Depends(get_api_key_auth),
|
|
283
|
+
jwt_user: Optional[User] = Depends(get_current_user_optional),
|
|
284
|
+
) -> Dict[str, Any]:
|
|
285
|
+
"""Require authentication via either API key or JWT token.
|
|
286
|
+
|
|
287
|
+
API keys take precedence if both are present.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
api_key_auth: Result from get_api_key_auth (if API key provided)
|
|
291
|
+
jwt_user: Result from get_current_user_optional (if JWT provided)
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Auth dict with: type, user_id, scopes, and optional user/key_id
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
HTTPException: 401 if no valid authentication provided
|
|
298
|
+
"""
|
|
299
|
+
# Prefer API key if provided
|
|
300
|
+
if api_key_auth is not None:
|
|
301
|
+
return api_key_auth
|
|
302
|
+
|
|
303
|
+
# Fall back to JWT
|
|
304
|
+
if jwt_user is not None:
|
|
305
|
+
return {
|
|
306
|
+
"type": "jwt",
|
|
307
|
+
"user_id": jwt_user.id,
|
|
308
|
+
# JWT users get all scopes for backward compatibility
|
|
309
|
+
"scopes": [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN],
|
|
310
|
+
"user": jwt_user,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Auth disabled (local opt-out): return a synthetic local-admin principal
|
|
314
|
+
# instead of raising. Real credentials above always take precedence.
|
|
315
|
+
if not auth_required():
|
|
316
|
+
return {
|
|
317
|
+
"type": "disabled",
|
|
318
|
+
"user_id": None,
|
|
319
|
+
"scopes": [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN],
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# No authentication provided
|
|
323
|
+
raise HTTPException(
|
|
324
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
325
|
+
detail="Authentication required",
|
|
326
|
+
headers={"WWW-Authenticate": "Bearer, ApiKey"},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def require_scope(required_scope: str) -> Callable:
|
|
331
|
+
"""Create a dependency that checks for a required scope.
|
|
332
|
+
|
|
333
|
+
Scope Hierarchy:
|
|
334
|
+
- admin: grants read, write, and admin permissions
|
|
335
|
+
- write: grants read and write permissions
|
|
336
|
+
- read: grants read permission only
|
|
337
|
+
|
|
338
|
+
Usage:
|
|
339
|
+
@router.post("/resource")
|
|
340
|
+
async def create_resource(auth: dict = Depends(require_scope("write"))):
|
|
341
|
+
...
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
required_scope: The scope required for access (read, write, or admin)
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Dependency function that validates scope
|
|
348
|
+
"""
|
|
349
|
+
async def check_scope(auth: Dict[str, Any] = Depends(require_auth)) -> Dict[str, Any]:
|
|
350
|
+
"""Verify principal has required scope."""
|
|
351
|
+
if not has_scope(auth, required_scope):
|
|
352
|
+
raise HTTPException(
|
|
353
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
354
|
+
detail=f"Insufficient permissions: '{required_scope}' scope required",
|
|
355
|
+
)
|
|
356
|
+
return auth
|
|
357
|
+
|
|
358
|
+
return check_scope
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""User manager and authentication backends."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from typing import AsyncGenerator, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, Request
|
|
7
|
+
from fastapi_users import BaseUserManager, IntegerIDMixin, FastAPIUsers
|
|
8
|
+
from fastapi_users.authentication import (
|
|
9
|
+
AuthenticationBackend,
|
|
10
|
+
BearerTransport,
|
|
11
|
+
JWTStrategy,
|
|
12
|
+
)
|
|
13
|
+
from fastapi_users.db import SQLAlchemyUserDatabase
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
15
|
+
|
|
16
|
+
from codeframe.auth.models import User
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Get configuration from environment
|
|
21
|
+
DEFAULT_SECRET = "CHANGE-ME-IN-PRODUCTION"
|
|
22
|
+
SECRET = os.getenv("AUTH_SECRET", DEFAULT_SECRET)
|
|
23
|
+
|
|
24
|
+
# JWT configuration constants
|
|
25
|
+
# These must match the JWTStrategy defaults from FastAPI Users
|
|
26
|
+
JWT_ALGORITHM = "HS256"
|
|
27
|
+
JWT_AUDIENCE = ["fastapi-users:auth"]
|
|
28
|
+
JWT_LIFETIME_SECONDS = int(os.getenv("JWT_LIFETIME_SECONDS", "604800")) # 7 days
|
|
29
|
+
|
|
30
|
+
# Warn if using default secret (but allow for development)
|
|
31
|
+
if SECRET == DEFAULT_SECRET:
|
|
32
|
+
logger.warning(
|
|
33
|
+
"⚠️ AUTH_SECRET not set - using default value. "
|
|
34
|
+
"DO NOT USE IN PRODUCTION! Set AUTH_SECRET environment variable."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Create async SQLAlchemy engine for auth
|
|
38
|
+
# Uses aiosqlite driver for async SQLite access
|
|
39
|
+
_engine = None
|
|
40
|
+
_async_session_maker = None
|
|
41
|
+
_current_database_path = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_database_path() -> str:
|
|
45
|
+
"""Get the current database path from environment."""
|
|
46
|
+
return os.getenv(
|
|
47
|
+
"DATABASE_PATH",
|
|
48
|
+
os.path.join(os.getcwd(), ".codeframe", "state.db")
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def reset_auth_engine():
|
|
53
|
+
"""Reset the async SQLAlchemy engine.
|
|
54
|
+
|
|
55
|
+
Call this when DATABASE_PATH environment variable changes
|
|
56
|
+
(e.g., in tests that use temporary databases).
|
|
57
|
+
|
|
58
|
+
Also disposes of the engine to close all connections.
|
|
59
|
+
"""
|
|
60
|
+
global _engine, _async_session_maker, _current_database_path
|
|
61
|
+
|
|
62
|
+
# Dispose of engine to close all connections
|
|
63
|
+
if _engine is not None:
|
|
64
|
+
import asyncio
|
|
65
|
+
try:
|
|
66
|
+
# Try to dispose synchronously if possible
|
|
67
|
+
loop = asyncio.get_event_loop()
|
|
68
|
+
if loop.is_running():
|
|
69
|
+
# Can't await in running loop, schedule for later
|
|
70
|
+
asyncio.ensure_future(_engine.dispose())
|
|
71
|
+
else:
|
|
72
|
+
loop.run_until_complete(_engine.dispose())
|
|
73
|
+
except RuntimeError:
|
|
74
|
+
# No event loop available, create one temporarily
|
|
75
|
+
asyncio.run(_engine.dispose())
|
|
76
|
+
except Exception:
|
|
77
|
+
# Ignore disposal errors during cleanup
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
_engine = None
|
|
81
|
+
_async_session_maker = None
|
|
82
|
+
_current_database_path = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_engine():
|
|
86
|
+
"""Get or create the async SQLAlchemy engine."""
|
|
87
|
+
global _engine, _current_database_path
|
|
88
|
+
|
|
89
|
+
# Get current database path
|
|
90
|
+
database_path = _get_database_path()
|
|
91
|
+
|
|
92
|
+
# If path changed, reset engine
|
|
93
|
+
if _current_database_path is not None and _current_database_path != database_path:
|
|
94
|
+
reset_auth_engine()
|
|
95
|
+
|
|
96
|
+
if _engine is None:
|
|
97
|
+
# Use aiosqlite for async SQLite support
|
|
98
|
+
database_url = f"sqlite+aiosqlite:///{database_path}"
|
|
99
|
+
_engine = create_async_engine(database_url, echo=False)
|
|
100
|
+
_current_database_path = database_path
|
|
101
|
+
return _engine
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_async_session_maker():
|
|
105
|
+
"""Get or create the async session maker."""
|
|
106
|
+
global _async_session_maker
|
|
107
|
+
if _async_session_maker is None:
|
|
108
|
+
_async_session_maker = async_sessionmaker(
|
|
109
|
+
get_engine(),
|
|
110
|
+
class_=AsyncSession,
|
|
111
|
+
expire_on_commit=False,
|
|
112
|
+
)
|
|
113
|
+
return _async_session_maker
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class UserManager(IntegerIDMixin, BaseUserManager[User, int]):
|
|
117
|
+
"""User manager for CodeFRAME."""
|
|
118
|
+
|
|
119
|
+
reset_password_token_secret = SECRET
|
|
120
|
+
verification_token_secret = SECRET
|
|
121
|
+
|
|
122
|
+
async def on_after_register(self, user: User, request: Optional[Request] = None):
|
|
123
|
+
"""Called after successful registration."""
|
|
124
|
+
logger.info(
|
|
125
|
+
"User registered",
|
|
126
|
+
extra={"user_id": user.id, "email": user.email}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def on_after_login(
|
|
130
|
+
self, user: User, request: Optional[Request] = None, response=None
|
|
131
|
+
):
|
|
132
|
+
"""Called after successful login."""
|
|
133
|
+
# Only log user_id on login (avoid excessive email logging)
|
|
134
|
+
logger.info("User logged in", extra={"user_id": user.id})
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
|
138
|
+
"""Get async database session for auth."""
|
|
139
|
+
async_session_maker = get_async_session_maker()
|
|
140
|
+
async with async_session_maker() as session:
|
|
141
|
+
yield session
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
|
|
145
|
+
"""Get user database adapter."""
|
|
146
|
+
yield SQLAlchemyUserDatabase(session, User)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
|
|
150
|
+
"""Get user manager."""
|
|
151
|
+
yield UserManager(user_db)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# JWT Bearer token transport
|
|
155
|
+
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_jwt_strategy() -> JWTStrategy:
|
|
159
|
+
"""JWT strategy for authentication."""
|
|
160
|
+
return JWTStrategy(secret=SECRET, lifetime_seconds=JWT_LIFETIME_SECONDS)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Authentication backend
|
|
164
|
+
auth_backend = AuthenticationBackend(
|
|
165
|
+
name="jwt",
|
|
166
|
+
transport=bearer_transport,
|
|
167
|
+
get_strategy=get_jwt_strategy,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# FastAPIUsers instance
|
|
171
|
+
fastapi_users = FastAPIUsers[User, int](
|
|
172
|
+
get_user_manager,
|
|
173
|
+
[auth_backend],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Dependencies for protected routes
|
|
177
|
+
current_active_user = fastapi_users.current_user(active=True)
|
|
178
|
+
current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
codeframe/auth/models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""SQLAlchemy User model for fastapi-users."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from fastapi_users.db import SQLAlchemyBaseUserTable
|
|
4
|
+
from sqlalchemy import Integer, String, Boolean
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase
|
|
6
|
+
|
|
7
|
+
class Base(DeclarativeBase):
|
|
8
|
+
"""SQLAlchemy declarative base."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
class User(SQLAlchemyBaseUserTable[int], Base):
|
|
12
|
+
"""User model compatible with existing users table.
|
|
13
|
+
|
|
14
|
+
Uses integer primary key instead of UUID to match existing schema.
|
|
15
|
+
Maps to existing 'users' table created by SchemaManager.
|
|
16
|
+
"""
|
|
17
|
+
__tablename__ = "users"
|
|
18
|
+
|
|
19
|
+
# Override id to use Integer instead of UUID
|
|
20
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
21
|
+
|
|
22
|
+
# Required fastapi-users fields (already in schema)
|
|
23
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
|
24
|
+
hashed_password: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
25
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
26
|
+
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
27
|
+
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
28
|
+
|
|
29
|
+
# Additional fields from existing schema
|
|
30
|
+
name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
codeframe/auth/router.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Auth router configuration."""
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
5
|
+
from sqlalchemy import func, select
|
|
6
|
+
|
|
7
|
+
from codeframe.auth.schemas import UserCreate, UserRead, UserUpdate
|
|
8
|
+
from codeframe.auth.manager import auth_backend, fastapi_users, get_async_session_maker
|
|
9
|
+
from codeframe.auth.models import User
|
|
10
|
+
from codeframe.auth.api_key_router import router as api_key_router
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Placeholder password for the seeded bootstrap admin (id=1). It cannot match
|
|
16
|
+
# any bcrypt hash, so that account can never log in. It is therefore NOT a real
|
|
17
|
+
# account and does not close the registration window. See SchemaManager
|
|
18
|
+
# ._ensure_default_admin_user.
|
|
19
|
+
_DISABLED_PASSWORD = "!DISABLED!"
|
|
20
|
+
|
|
21
|
+
# Serializes the bootstrap registration check-then-create window. The count
|
|
22
|
+
# check (here) and the user INSERT (fastapi-users route handler) run in
|
|
23
|
+
# separate transactions, so without the lock two concurrent first-time
|
|
24
|
+
# registrations could both pass the zero-users check (TOCTOU). The yield
|
|
25
|
+
# dependency holds the lock until the response completes, covering creation.
|
|
26
|
+
# In-process only — multi-worker deployments retain a narrow race.
|
|
27
|
+
_register_lock = asyncio.Lock()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def allow_registration():
|
|
31
|
+
"""Gate registration to bootstrap-first-user only (issue #336).
|
|
32
|
+
|
|
33
|
+
Registration is permitted only while no *real* (login-capable) account
|
|
34
|
+
exists. The database always seeds a default admin (id=1) with a disabled
|
|
35
|
+
password placeholder; that account cannot log in and does not count.
|
|
36
|
+
Once the first real user registers, this raises 403.
|
|
37
|
+
"""
|
|
38
|
+
async with _register_lock:
|
|
39
|
+
async_session_maker = get_async_session_maker()
|
|
40
|
+
async with async_session_maker() as session:
|
|
41
|
+
result = await session.execute(
|
|
42
|
+
select(func.count())
|
|
43
|
+
.select_from(User)
|
|
44
|
+
.where(User.hashed_password != _DISABLED_PASSWORD)
|
|
45
|
+
)
|
|
46
|
+
real_user_count = result.scalar_one()
|
|
47
|
+
|
|
48
|
+
if real_user_count > 0:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
51
|
+
detail="Registration is closed: an account already exists.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Hold the lock until the registration request finishes.
|
|
55
|
+
yield
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Authentication routes (login, logout) - JWT endpoints at /auth/jwt/*
|
|
59
|
+
router.include_router(
|
|
60
|
+
fastapi_users.get_auth_router(auth_backend),
|
|
61
|
+
prefix="/auth/jwt",
|
|
62
|
+
tags=["auth"],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Registration route at /auth/register (bootstrap-first-user only)
|
|
66
|
+
router.include_router(
|
|
67
|
+
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
68
|
+
prefix="/auth",
|
|
69
|
+
tags=["auth"],
|
|
70
|
+
dependencies=[Depends(allow_registration)],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# User management routes (get me, update me) at /users/*
|
|
74
|
+
router.include_router(
|
|
75
|
+
fastapi_users.get_users_router(UserRead, UserUpdate),
|
|
76
|
+
prefix="/users",
|
|
77
|
+
tags=["users"],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Optional: Reset password, verify email
|
|
81
|
+
# router.include_router(
|
|
82
|
+
# fastapi_users.get_reset_password_router(),
|
|
83
|
+
# prefix="/auth",
|
|
84
|
+
# tags=["auth"],
|
|
85
|
+
# )
|
|
86
|
+
# router.include_router(
|
|
87
|
+
# fastapi_users.get_verify_router(UserRead),
|
|
88
|
+
# prefix="/auth",
|
|
89
|
+
# tags=["auth"],
|
|
90
|
+
# )
|
|
91
|
+
|
|
92
|
+
# API key management routes at /api/auth/api-keys
|
|
93
|
+
router.include_router(api_key_router)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Pydantic schemas for user operations."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from fastapi_users import schemas
|
|
4
|
+
|
|
5
|
+
class UserRead(schemas.BaseUser[int]):
|
|
6
|
+
"""Schema for reading user data."""
|
|
7
|
+
name: Optional[str] = None
|
|
8
|
+
|
|
9
|
+
class UserCreate(schemas.BaseUserCreate):
|
|
10
|
+
"""Schema for creating users."""
|
|
11
|
+
name: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
class UserUpdate(schemas.BaseUserUpdate):
|
|
14
|
+
"""Schema for updating users."""
|
|
15
|
+
name: Optional[str] = None
|