codepulse-engine 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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
codepulse/api/errors.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phase 1.6 — Structured error response format across all endpoints
|
|
3
|
+
(roadmap §37 item 1.6; §21 API Strategy: "Errors follow a single shape:
|
|
4
|
+
`{"error": {"code": "...", "message": "...", "details": {...}}}`").
|
|
5
|
+
|
|
6
|
+
Scope, precisely (do not infer requirements from later milestones):
|
|
7
|
+
- Every error response this REST API returns -- from every existing
|
|
8
|
+
`HTTPException` raise site (401/403/404/409/429/501), from FastAPI's
|
|
9
|
+
own automatic request-validation failures (422), from the rate-limit
|
|
10
|
+
middleware's own 429 (Phase 1.4, constructed directly rather than
|
|
11
|
+
raised -- see below for why it needs its own explicit call), and from
|
|
12
|
+
a genuinely unhandled exception (500) -- is reshaped into the single
|
|
13
|
+
§21 envelope, via handlers registered ONCE on `app` (the same "one
|
|
14
|
+
central choke point, not re-implemented per endpoint" principle
|
|
15
|
+
already established for authorization in Phase 1.2 and rate limiting
|
|
16
|
+
in Phase 1.4). No individual route/endpoint function's own
|
|
17
|
+
`raise HTTPException(...)` call sites needed to change to achieve
|
|
18
|
+
this -- reshaping happens centrally, after the fact.
|
|
19
|
+
- `code` is derived deterministically from the HTTP status via Python's
|
|
20
|
+
own `http.HTTPStatus` enum (e.g. 404 -> "not_found", 429 ->
|
|
21
|
+
"too_many_requests", 501 -> "not_implemented") -- not an invented
|
|
22
|
+
string table, so every status code already in use in this app (and
|
|
23
|
+
any added later) maps correctly without this module needing a
|
|
24
|
+
per-code entry.
|
|
25
|
+
- `message` is the existing error text, unchanged -- no error-message
|
|
26
|
+
wording anywhere in the app was rewritten by this milestone.
|
|
27
|
+
- `details` carries real, already-computed structured information where
|
|
28
|
+
it exists (currently: the rate limiter's real `retry_after_seconds`
|
|
29
|
+
for 429s -- the exact same value already used for the `Retry-After`
|
|
30
|
+
header, roadmap-required since Phase 1.4 -- and the raw Pydantic
|
|
31
|
+
validation error list for 422s) and is an empty `{}` otherwise --
|
|
32
|
+
never a fabricated/estimated value, per this project's "no fabricated
|
|
33
|
+
metrics" invariant applied to error payloads too.
|
|
34
|
+
- A genuinely unhandled exception (500) is logged server-side
|
|
35
|
+
(`logger.exception`, so it is never silently swallowed) and reported
|
|
36
|
+
to the client with a generic, non-leaking `message`
|
|
37
|
+
("Internal server error") and empty `details` -- never the raw
|
|
38
|
+
exception text or a traceback, to avoid an information-disclosure
|
|
39
|
+
regression while still adopting the new envelope shape.
|
|
40
|
+
|
|
41
|
+
Why the rate-limit middleware needs its own explicit call to
|
|
42
|
+
`error_json_response` rather than just `raise HTTPException(...)`:
|
|
43
|
+
Starlette's `ExceptionMiddleware` (which dispatches to `@app.exception_
|
|
44
|
+
handler(...)` registrations) sits INSIDE the app's user-added middleware
|
|
45
|
+
stack -- closer to routing, not wrapping it. An exception raised from
|
|
46
|
+
inside a custom `@app.middleware("http")` function (like the Phase 1.4
|
|
47
|
+
rate limiter) is never seen by `ExceptionMiddleware` at all, so a plain
|
|
48
|
+
`raise HTTPException(...)` there would never reach these handlers. Both
|
|
49
|
+
the middleware and the exception handlers below call the exact same
|
|
50
|
+
`error_json_response` helper instead, so the shaping logic itself is
|
|
51
|
+
never duplicated between the two call sites.
|
|
52
|
+
|
|
53
|
+
Deliberately NOT in scope for Phase 1.6 (do not infer these belong here):
|
|
54
|
+
- Changing any error's HTTP status code or message wording -- this
|
|
55
|
+
milestone reshapes the existing responses; it does not redesign what
|
|
56
|
+
they say.
|
|
57
|
+
- MCP's own distinct error shape (roadmap §13: a flat
|
|
58
|
+
`{"error": "code_string", "message": "..."}`, no "details") -- a
|
|
59
|
+
different section's requirement for a surface (MCP, Phase 5) that
|
|
60
|
+
does not exist yet; this module implements only §21's REST shape.
|
|
61
|
+
- A security test suite (Phase 1.7).
|
|
62
|
+
|
|
63
|
+
This module lives in `codepulse/api/` (not a top-level module like
|
|
64
|
+
`auth.py`/`jobs.py`/`rate_limit.py`) deliberately: unlike those, this is a
|
|
65
|
+
REST-HTTP-response-shape concern specifically (§21's envelope), not a
|
|
66
|
+
capability a future CLI or MCP surface would reuse -- MCP has its own,
|
|
67
|
+
different error shape per §13, and the CLI never returns HTTP responses
|
|
68
|
+
at all.
|
|
69
|
+
"""
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import logging
|
|
73
|
+
from http import HTTPStatus
|
|
74
|
+
from typing import Any
|
|
75
|
+
|
|
76
|
+
from fastapi import FastAPI, Request
|
|
77
|
+
from fastapi.exceptions import RequestValidationError
|
|
78
|
+
from fastapi.responses import JSONResponse
|
|
79
|
+
|
|
80
|
+
# Registered on Starlette's BASE HTTPException (not `fastapi.HTTPException`,
|
|
81
|
+
# which is a subclass): Starlette's own router raises the base class for an
|
|
82
|
+
# unmatched path (404) or wrong method (405), so a handler registered only on
|
|
83
|
+
# the subclass never matched those and they bypassed the §21 envelope
|
|
84
|
+
# (2026-10-02 audit). Exception handlers are looked up through the MRO, so
|
|
85
|
+
# this ALSO still catches every `fastapi.HTTPException` raised by the routes.
|
|
86
|
+
from starlette.exceptions import HTTPException
|
|
87
|
+
|
|
88
|
+
logger = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def status_code_to_error_code(status_code: int) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Derives a stable, machine-parseable error `code` from an HTTP status
|
|
94
|
+
code via Python's own `http.HTTPStatus` enum -- e.g. 404 -> "not_found",
|
|
95
|
+
429 -> "too_many_requests" -- rather than an invented string table.
|
|
96
|
+
Falls back to the generic "error" for a status code `HTTPStatus`
|
|
97
|
+
itself doesn't recognize.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
return HTTPStatus(status_code).name.lower()
|
|
101
|
+
except ValueError:
|
|
102
|
+
return "error"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def error_json_response(
|
|
106
|
+
status_code: int,
|
|
107
|
+
message: str,
|
|
108
|
+
details: dict[str, Any] | None = None,
|
|
109
|
+
headers: dict[str, str] | None = None,
|
|
110
|
+
) -> JSONResponse:
|
|
111
|
+
"""
|
|
112
|
+
Builds the single §21 error envelope as a real `JSONResponse`:
|
|
113
|
+
`{"error": {"code", "message", "details"}}`. The one place this
|
|
114
|
+
shape is actually constructed -- every call site below (and the
|
|
115
|
+
rate-limit middleware) goes through this, never duplicating the
|
|
116
|
+
shaping logic.
|
|
117
|
+
"""
|
|
118
|
+
return JSONResponse(
|
|
119
|
+
status_code=status_code,
|
|
120
|
+
content={
|
|
121
|
+
"error": {
|
|
122
|
+
"code": status_code_to_error_code(status_code),
|
|
123
|
+
"message": message,
|
|
124
|
+
"details": details or {},
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
headers=headers,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Registers the structured-error-response handlers on `app`. Called
|
|
134
|
+
once at app construction time in `codepulse/api/app.py`.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
@app.exception_handler(HTTPException)
|
|
138
|
+
async def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
139
|
+
details: dict[str, Any] = {}
|
|
140
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
141
|
+
if retry_after is not None:
|
|
142
|
+
details["retry_after_seconds"] = int(retry_after)
|
|
143
|
+
# A router-level 404/405 has no matched endpoint in scope. Counting
|
|
144
|
+
# those under the raw URL would let anything probing random paths
|
|
145
|
+
# mint an unbounded number of Prometheus series, so they share one
|
|
146
|
+
# fixed label instead.
|
|
147
|
+
endpoint = request.url.path if "endpoint" in request.scope else "unmatched"
|
|
148
|
+
_record_api_error(request, exc.status_code, endpoint=endpoint)
|
|
149
|
+
return error_json_response(exc.status_code, str(exc.detail), details, headers=exc.headers)
|
|
150
|
+
|
|
151
|
+
@app.exception_handler(RequestValidationError)
|
|
152
|
+
async def _validation_exception_handler(
|
|
153
|
+
request: Request, exc: RequestValidationError
|
|
154
|
+
) -> JSONResponse:
|
|
155
|
+
_record_api_error(request, HTTPStatus.UNPROCESSABLE_ENTITY)
|
|
156
|
+
return error_json_response(
|
|
157
|
+
HTTPStatus.UNPROCESSABLE_ENTITY,
|
|
158
|
+
"Request validation failed",
|
|
159
|
+
{"errors": exc.errors()},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@app.exception_handler(Exception)
|
|
163
|
+
async def _unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
164
|
+
logger.exception("Unhandled exception in request %s %s", request.method, request.url.path)
|
|
165
|
+
_record_api_error(request, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
166
|
+
return error_json_response(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _record_api_error(request: Request, status_code: int, endpoint: str | None = None) -> None:
|
|
170
|
+
"""Phase 7.4 (roadmap §37 item 7.4; §18: "api_errors_total{endpoint,status_code}") -- one call site, reused by every handler above and the rate-limit middleware (codepulse/api/app.py)."""
|
|
171
|
+
from codepulse.observability import api_errors_total
|
|
172
|
+
api_errors_total.labels(endpoint=endpoint or request.url.path, status_code=str(int(status_code))).inc()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phase 1.2 — Central authorization dependency (roadmap §37 item 1.2; see also
|
|
3
|
+
§15 Security Architecture, §21 API Strategy, §30 Multi-Tenancy, and backlog
|
|
4
|
+
item B8: "API key auth + central authorization").
|
|
5
|
+
|
|
6
|
+
Definition of Done extracted from the roadmap (do not infer beyond this):
|
|
7
|
+
- §37 item 1.2, literally: "Central authorization dependency (FastAPI
|
|
8
|
+
dependency injected into every route)".
|
|
9
|
+
- B8's DoD: "No endpoint callable without a valid, scoped key."
|
|
10
|
+
- §21: "Every endpoint requires an API key (§15) except `/health/*`." No
|
|
11
|
+
`/health/*` endpoint exists yet (a later milestone), so this currently
|
|
12
|
+
means every one of the 12 existing endpoints.
|
|
13
|
+
- §15/§30: repository-scoped isolation must be enforced "in one central
|
|
14
|
+
authorization function all endpoints must call, not re-implemented per
|
|
15
|
+
endpoint" -- exactly what `require_api_key` below is, and exactly the
|
|
16
|
+
"search ignores repository_id" class of bug this closes.
|
|
17
|
+
|
|
18
|
+
This module provides that one central function. `codepulse/api/app.py`
|
|
19
|
+
injects it into every route via `Depends(require_api_key)`; nothing
|
|
20
|
+
duplicates its logic per-endpoint.
|
|
21
|
+
|
|
22
|
+
Scope, precisely -- deliberately NOT inferring later milestones:
|
|
23
|
+
- Reject a request with a missing, unknown, malformed, or revoked API key
|
|
24
|
+
(HTTP 401). Reuses `codepulse.auth.verify_api_key` (Phase 1.1) as the
|
|
25
|
+
sole verification mechanism -- this module does not implement a second
|
|
26
|
+
authentication mechanism.
|
|
27
|
+
- For a request that names a specific repository (a path parameter, a
|
|
28
|
+
body field, or a symbol/file resolved to its owning repository), reject
|
|
29
|
+
it (HTTP 403) unless the authenticated key is explicitly scoped to that
|
|
30
|
+
repository. An API key created with no repository grants at all (Phase
|
|
31
|
+
1.1 permits this) is authenticated but authorized for NO
|
|
32
|
+
repository-scoped operation: fail-closed, least-privilege, matching the
|
|
33
|
+
roadmap's literal "valid, SCOPED key" wording (not "valid key" alone).
|
|
34
|
+
There is no wildcard/superuser key in this milestone.
|
|
35
|
+
|
|
36
|
+
Deliberately NOT in scope for Phase 1.2 (reserved for later roadmap
|
|
37
|
+
milestones -- do not infer these belong here):
|
|
38
|
+
- Role-based permissions (viewer/indexer/admin, roadmap §15) -- Phase 7 /
|
|
39
|
+
B25 (backlog: "APIKey/authorization extension", depends on B8).
|
|
40
|
+
- Rate limiting -- Phase 1.4.
|
|
41
|
+
- Ingestion-specific safety (path allow-listing, SSRF checks, size caps)
|
|
42
|
+
-- Phase 1.5 / B10.
|
|
43
|
+
- A structured `{"error": {"code", "message", "details"}}` response
|
|
44
|
+
envelope for every endpoint -- Phase 1.6. This module raises plain
|
|
45
|
+
`HTTPException`s with a `detail` string, matching every other error
|
|
46
|
+
path already in `api/app.py`; reshaping error responses project-wide is
|
|
47
|
+
1.6's job.
|
|
48
|
+
- A dedicated security test suite -- Phase 1.7. This milestone's own
|
|
49
|
+
focused tests cover only its own behavior.
|
|
50
|
+
|
|
51
|
+
This module lives in `codepulse/api/` -- the API boundary -- deliberately,
|
|
52
|
+
per CLAUDE.md §3 ("authentication belongs at the API boundary; the
|
|
53
|
+
intelligence core must remain independent of HTTP/auth concerns").
|
|
54
|
+
`codepulse/indexer/engine.py` is not imported here and is not modified by
|
|
55
|
+
this milestone.
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
|
|
61
|
+
from fastapi import Header, HTTPException, status
|
|
62
|
+
|
|
63
|
+
from codepulse.auth import ROLE_RANK, verify_api_key
|
|
64
|
+
from codepulse.storage.db import APIKeyRepositoryModel
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AuthorizedKey:
|
|
69
|
+
"""
|
|
70
|
+
Authorization context for one authenticated request.
|
|
71
|
+
|
|
72
|
+
`repository_ids` is the exact, complete set of repository ids the
|
|
73
|
+
authenticated key is scoped to (Phase 1.1's `api_key_repositories`
|
|
74
|
+
grant table) -- empty if the key was created with no repository grants
|
|
75
|
+
at all. There is no sentinel meaning "all repositories": every
|
|
76
|
+
repository-scoped endpoint must check membership in this set via
|
|
77
|
+
`authorize_repository`/`authorize_search_scope`.
|
|
78
|
+
|
|
79
|
+
Phase 7.2 (roadmap §37 item 7.2): `repository_roles` maps each
|
|
80
|
+
granted `repository_id` to its own `role` (`"viewer"`/`"indexer"`/
|
|
81
|
+
`"admin"`) -- per §15's "enforced at the repository-permission
|
|
82
|
+
level, not globally," a single key can hold DIFFERENT roles for
|
|
83
|
+
different repositories. `authorize_role` is the role-aware
|
|
84
|
+
counterpart to `authorize_repository`; membership-only endpoints
|
|
85
|
+
keep using `authorize_repository`/`authorize_search_scope`
|
|
86
|
+
unchanged (every role implies at least "viewer," so a plain
|
|
87
|
+
membership check remains correct for read-only endpoints -- it is
|
|
88
|
+
not a security gap, just a coarser check than `authorize_role`
|
|
89
|
+
would make for those routes).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
api_key_id: int
|
|
93
|
+
repository_ids: frozenset[int]
|
|
94
|
+
repository_roles: dict[int, str] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
def authorize_repository(self, repository_id: int) -> None:
|
|
97
|
+
"""Raises HTTP 403 unless this key is scoped to `repository_id`."""
|
|
98
|
+
if repository_id not in self.repository_ids:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
101
|
+
detail=f"API key is not scoped to repository {repository_id}",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def authorize_role(self, repository_id: int, required_role: str) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Raises HTTP 403 unless this key is scoped to `repository_id`
|
|
107
|
+
AND its role there is AT LEAST `required_role` (§15's ordering:
|
|
108
|
+
`viewer` < `indexer` < `admin`). Always checks membership first
|
|
109
|
+
(via `authorize_repository`, reused unmodified) so an
|
|
110
|
+
unauthorized-repository request gets the same 403 it always
|
|
111
|
+
has, before any role-specific detail is considered.
|
|
112
|
+
"""
|
|
113
|
+
self.authorize_repository(repository_id)
|
|
114
|
+
held_role = self.repository_roles.get(repository_id)
|
|
115
|
+
if ROLE_RANK.get(held_role, -1) < ROLE_RANK[required_role]:
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
118
|
+
detail=(
|
|
119
|
+
f"API key has role {held_role!r} for repository {repository_id}, "
|
|
120
|
+
f"but {required_role!r} (or higher) is required for this operation."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def authorize_search_scope(self, repository_id: int | None) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Authorizes a search-style request whose `repository_id` may
|
|
127
|
+
legitimately be omitted (`None`), meaning "search across every
|
|
128
|
+
repository" -- an existing feature of `IndexingEngine.search`
|
|
129
|
+
(Phase 0.6). A `None` repository_id cannot be authorized against
|
|
130
|
+
this key's specific repository grants without either (a) unsafely
|
|
131
|
+
letting it reach repositories outside its scope -- exactly the
|
|
132
|
+
cross-repo leakage class this milestone exists to close (roadmap
|
|
133
|
+
§30) -- or (b) teaching `IndexingEngine.search` to filter by a
|
|
134
|
+
*set* of repository ids, which would couple authorization logic
|
|
135
|
+
into the intelligence core (explicitly out of scope for this
|
|
136
|
+
milestone). So a `None` repository_id is rejected outright here;
|
|
137
|
+
the caller must name the specific repository it wants to search.
|
|
138
|
+
"""
|
|
139
|
+
if repository_id is None:
|
|
140
|
+
raise HTTPException(
|
|
141
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
142
|
+
detail=(
|
|
143
|
+
"A specific repository_id is required to authorize this "
|
|
144
|
+
"request; unscoped/global search cannot be authorized "
|
|
145
|
+
"against a scoped API key."
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
self.authorize_repository(repository_id)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def require_api_key(
|
|
152
|
+
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
|
153
|
+
) -> AuthorizedKey:
|
|
154
|
+
"""
|
|
155
|
+
FastAPI dependency: verifies the `X-API-Key` request header and returns
|
|
156
|
+
the caller's `AuthorizedKey` context, or raises HTTP 401.
|
|
157
|
+
|
|
158
|
+
Resolves `codepulse.api.app`'s CURRENT `storage_manager` at call time
|
|
159
|
+
via a deferred import (not a module-level one), so tests that
|
|
160
|
+
monkeypatch `codepulse.api.app.storage_manager` to an isolated database
|
|
161
|
+
-- the established pattern since Phase 0.7's
|
|
162
|
+
`test_no_fabricated_metrics.py` -- are honored here exactly as they
|
|
163
|
+
already are for `indexing_engine`. This dependency does not open a
|
|
164
|
+
second, independent database connection, and never touches the real
|
|
165
|
+
`codepulse.db` from a test.
|
|
166
|
+
"""
|
|
167
|
+
if not x_api_key:
|
|
168
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key")
|
|
169
|
+
|
|
170
|
+
import codepulse.api.app as app_module # deferred: avoids a circular import; re-reads the module's current storage_manager on every call
|
|
171
|
+
|
|
172
|
+
api_key_id = verify_api_key(app_module.storage_manager, x_api_key)
|
|
173
|
+
if api_key_id is None:
|
|
174
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or revoked API key")
|
|
175
|
+
|
|
176
|
+
session = app_module.storage_manager.get_session()
|
|
177
|
+
try:
|
|
178
|
+
grants = {
|
|
179
|
+
row.repo_id: row.role
|
|
180
|
+
for row in session.query(APIKeyRepositoryModel)
|
|
181
|
+
.filter(APIKeyRepositoryModel.api_key_id == api_key_id)
|
|
182
|
+
.all()
|
|
183
|
+
}
|
|
184
|
+
finally:
|
|
185
|
+
session.close()
|
|
186
|
+
|
|
187
|
+
return AuthorizedKey(
|
|
188
|
+
api_key_id=api_key_id, repository_ids=frozenset(grants.keys()), repository_roles=grants
|
|
189
|
+
)
|