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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. 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)
@@ -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)
@@ -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