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/auth.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Phase 1.1 — API key model + hashing + creation flow.
|
|
3
|
+
|
|
4
|
+
Scope, precisely (roadmap §37 item 1.1; see storage/db.py's APIKeyModel
|
|
5
|
+
docstring for the schema-level rationale):
|
|
6
|
+
|
|
7
|
+
- Generate a cryptographically random API key.
|
|
8
|
+
- Hash it for storage (never persist the raw key anywhere).
|
|
9
|
+
- A creation flow that returns the raw key to the caller exactly once.
|
|
10
|
+
- A verification PRIMITIVE (hash a candidate key, look up the matching
|
|
11
|
+
non-revoked row) -- proves the hashing/storage actually works and is
|
|
12
|
+
usable, but is NOT wired into any API endpoint, FastAPI dependency, or
|
|
13
|
+
request/response flow. That wiring -- "central authorization
|
|
14
|
+
dependency injected into every route" -- is explicitly Phase 1.2, not
|
|
15
|
+
implemented here.
|
|
16
|
+
|
|
17
|
+
Deliberately NOT in scope for Phase 1.1 (do not infer these from later
|
|
18
|
+
Phase 1 milestones): request-header parsing, HTTP 401/403 responses,
|
|
19
|
+
FastAPI `Depends()` wiring, rate limiting (roadmap §15's viewer/
|
|
20
|
+
indexer/admin roles were Phase 7 / B25 scope -- `create_api_key` below
|
|
21
|
+
now accepts a `role` per repository grant, see its own docstring), key
|
|
22
|
+
expiry, audit logging.
|
|
23
|
+
|
|
24
|
+
Backend-hardening addition (not a roadmap sprint item): a real,
|
|
25
|
+
mutating `revoke_api_key` now exists below, plus `APIKeySummary`/
|
|
26
|
+
`APIKeyGrant`/`get_api_key_repository_ids`/
|
|
27
|
+
`list_api_keys_for_repositories` -- the key-management primitives
|
|
28
|
+
`codepulse/api/app.py`'s new `/v1/admin/keys` endpoints are thin
|
|
29
|
+
wrappers over. `create_api_key` itself is unchanged by this addition.
|
|
30
|
+
|
|
31
|
+
This module lives outside `codepulse/indexer/` deliberately: per CLAUDE.md
|
|
32
|
+
§3, "authentication belongs at the API boundary; the intelligence core
|
|
33
|
+
must remain independent of HTTP/auth concerns" -- `IndexingEngine` and the
|
|
34
|
+
rest of the intelligence core are untouched by this milestone and do not
|
|
35
|
+
import from this module.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
import secrets
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
|
|
44
|
+
from sqlalchemy.exc import IntegrityError
|
|
45
|
+
|
|
46
|
+
from codepulse.repository_identity import canonical_repository_url, find_repository
|
|
47
|
+
from codepulse.storage.db import APIKeyModel, APIKeyRepositoryModel, RepositoryModel, StorageManager
|
|
48
|
+
|
|
49
|
+
API_KEY_PREFIX = "cpk_"
|
|
50
|
+
_RAW_KEY_ENTROPY_BYTES = 32 # 256 bits, via secrets.token_urlsafe
|
|
51
|
+
_KEY_PREFIX_DISPLAY_LENGTH = len(API_KEY_PREFIX) + 8 # e.g. "cpk_a1b2c3d4"
|
|
52
|
+
|
|
53
|
+
# Phase 7.2 (roadmap §37 item 7.2; §15's literal minimal-viable model):
|
|
54
|
+
# "roles `viewer` (search/read), `indexer` (viewer + trigger indexing),
|
|
55
|
+
# `admin` (all + manage keys/repos)." Ordered ranking used by
|
|
56
|
+
# `AuthorizedKey.authorize_role` (codepulse/api/security.py) --
|
|
57
|
+
# defined here, once, so both modules share the exact same vocabulary
|
|
58
|
+
# and ordering rather than each hardcoding its own copy.
|
|
59
|
+
VALID_ROLES = ("viewer", "indexer", "admin")
|
|
60
|
+
ROLE_RANK = {role: rank for rank, role in enumerate(VALID_ROLES)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class CreatedAPIKey:
|
|
65
|
+
"""
|
|
66
|
+
Result of `create_api_key`. `raw_key` is populated exactly once, here,
|
|
67
|
+
at creation time -- it is never persisted and cannot be recovered
|
|
68
|
+
later (the standard, expected lifecycle for API keys, matching how
|
|
69
|
+
GitHub/AWS/Stripe-style tokens work). Every other field mirrors a
|
|
70
|
+
column on the persisted `APIKeyModel` row.
|
|
71
|
+
"""
|
|
72
|
+
id: int
|
|
73
|
+
name: str
|
|
74
|
+
key_prefix: str
|
|
75
|
+
created_at: datetime
|
|
76
|
+
raw_key: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def generate_raw_api_key() -> str:
|
|
80
|
+
"""Generates a new, cryptographically random API key string."""
|
|
81
|
+
return f"{API_KEY_PREFIX}{secrets.token_urlsafe(_RAW_KEY_ENTROPY_BYTES)}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def hash_api_key(raw_key: str) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Hashes a raw API key for storage/lookup.
|
|
87
|
+
|
|
88
|
+
Uses the same SHA-256 hex-digest convention already established
|
|
89
|
+
throughout this project (see codepulse.models.compute_content_hash) --
|
|
90
|
+
a fast cryptographic hash, not a slow/salted password hash (bcrypt/
|
|
91
|
+
argon2), is the correct choice here: the input is already a
|
|
92
|
+
high-entropy, randomly generated secret (not a user-chosen low-entropy
|
|
93
|
+
password), so there is no offline-guessing risk a slow hash would
|
|
94
|
+
mitigate, and a fast hash keeps future lookup-by-hash authentication
|
|
95
|
+
(Phase 1.2) a simple indexed equality query rather than requiring a
|
|
96
|
+
per-row comparison.
|
|
97
|
+
"""
|
|
98
|
+
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_api_key(
|
|
102
|
+
storage: StorageManager,
|
|
103
|
+
name: str,
|
|
104
|
+
repository_ids: list[int] | None = None,
|
|
105
|
+
role: str = "viewer",
|
|
106
|
+
) -> CreatedAPIKey:
|
|
107
|
+
"""
|
|
108
|
+
Creates a new API key: generates a random raw key, persists only its
|
|
109
|
+
hash (+ a short non-secret prefix) and optional repository scoping,
|
|
110
|
+
and returns the raw key to the caller exactly once.
|
|
111
|
+
|
|
112
|
+
`repository_ids`, if given, must all reference existing repositories
|
|
113
|
+
-- this is validated (raising ValueError otherwise) so a key can never
|
|
114
|
+
silently claim to be scoped to a repository that doesn't exist.
|
|
115
|
+
|
|
116
|
+
Phase 7.2 (roadmap §37 item 7.2): `role` is applied uniformly to
|
|
117
|
+
every one of `repository_ids`' grant rows created in this one call
|
|
118
|
+
(a caller wanting DIFFERENT roles per repository calls this more
|
|
119
|
+
than once -- §15's "enforced at the repository-permission level,
|
|
120
|
+
not globally" means role lives on the grant row, it does not mean
|
|
121
|
+
one call must support a mixed batch; no roadmap text asks for
|
|
122
|
+
that). Must be one of `VALID_ROLES` (`ValueError` otherwise).
|
|
123
|
+
Defaults to `"viewer"` -- the SAFE, least-privilege choice for
|
|
124
|
+
every NEWLY created grant, deliberately different from
|
|
125
|
+
`APIKeyRepositoryModel.role`'s own schema-level default (`"admin"`,
|
|
126
|
+
which exists only to honestly backfill grants that predate this
|
|
127
|
+
role concept -- see that column's own docstring).
|
|
128
|
+
"""
|
|
129
|
+
if role not in VALID_ROLES:
|
|
130
|
+
raise ValueError(f"Invalid role {role!r}; must be one of {VALID_ROLES}")
|
|
131
|
+
|
|
132
|
+
session = storage.get_session()
|
|
133
|
+
try:
|
|
134
|
+
if repository_ids:
|
|
135
|
+
existing_ids = {
|
|
136
|
+
r.id
|
|
137
|
+
for r in session.query(RepositoryModel.id)
|
|
138
|
+
.filter(RepositoryModel.id.in_(repository_ids))
|
|
139
|
+
.all()
|
|
140
|
+
}
|
|
141
|
+
missing = set(repository_ids) - existing_ids
|
|
142
|
+
if missing:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"Cannot scope API key to nonexistent repository id(s): {sorted(missing)}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
raw_key = generate_raw_api_key()
|
|
148
|
+
now = datetime.utcnow()
|
|
149
|
+
|
|
150
|
+
api_key = APIKeyModel(
|
|
151
|
+
name=name,
|
|
152
|
+
key_hash=hash_api_key(raw_key),
|
|
153
|
+
key_prefix=raw_key[:_KEY_PREFIX_DISPLAY_LENGTH],
|
|
154
|
+
created_at=now,
|
|
155
|
+
)
|
|
156
|
+
session.add(api_key)
|
|
157
|
+
session.flush() # assign api_key.id
|
|
158
|
+
|
|
159
|
+
for repo_id in repository_ids or []:
|
|
160
|
+
session.add(APIKeyRepositoryModel(api_key_id=api_key.id, repo_id=repo_id, role=role))
|
|
161
|
+
|
|
162
|
+
session.commit()
|
|
163
|
+
|
|
164
|
+
return CreatedAPIKey(
|
|
165
|
+
id=api_key.id,
|
|
166
|
+
name=api_key.name,
|
|
167
|
+
key_prefix=api_key.key_prefix,
|
|
168
|
+
created_at=api_key.created_at,
|
|
169
|
+
raw_key=raw_key,
|
|
170
|
+
)
|
|
171
|
+
except Exception:
|
|
172
|
+
session.rollback()
|
|
173
|
+
raise
|
|
174
|
+
finally:
|
|
175
|
+
session.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class APIKeyGrant:
|
|
180
|
+
"""One (repository, role) grant for an API key -- mirrors one row of
|
|
181
|
+
`APIKeyRepositoryModel`."""
|
|
182
|
+
repository_id: int
|
|
183
|
+
role: str
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class APIKeySummary:
|
|
188
|
+
"""
|
|
189
|
+
A non-secret view of a persisted API key -- backend-hardening
|
|
190
|
+
addition (key-management REST endpoints), never includes the raw
|
|
191
|
+
key or its hash, only what `APIKeyModel` itself already exposes for
|
|
192
|
+
display purposes (see that model's own `key_prefix` rationale).
|
|
193
|
+
`grants` is the key's COMPLETE grant list, unfiltered -- a caller
|
|
194
|
+
that must not disclose a key's access to repositories it doesn't
|
|
195
|
+
itself administer (e.g. an admin-role-gated REST endpoint) is
|
|
196
|
+
responsible for filtering `.grants` down to its own visible set
|
|
197
|
+
before returning it to a client; the functions below make no
|
|
198
|
+
assumption about who is allowed to see what.
|
|
199
|
+
"""
|
|
200
|
+
id: int
|
|
201
|
+
name: str
|
|
202
|
+
key_prefix: str
|
|
203
|
+
created_at: datetime
|
|
204
|
+
revoked_at: datetime | None
|
|
205
|
+
grants: list[APIKeyGrant]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_api_key_repository_ids(storage: StorageManager, key_id: int) -> list[int] | None:
|
|
209
|
+
"""
|
|
210
|
+
Returns the repository ids `key_id` is granted to (possibly empty,
|
|
211
|
+
for an unscoped key), or `None` if no `APIKeyModel` with this id
|
|
212
|
+
exists at all -- the `None`/non-`None` distinction is what lets a
|
|
213
|
+
caller (an admin-role-gated REST endpoint) return a real 404
|
|
214
|
+
separately from an authorization decision.
|
|
215
|
+
"""
|
|
216
|
+
session = storage.get_session()
|
|
217
|
+
try:
|
|
218
|
+
api_key = session.query(APIKeyModel).filter(APIKeyModel.id == key_id).first()
|
|
219
|
+
if api_key is None:
|
|
220
|
+
return None
|
|
221
|
+
return [
|
|
222
|
+
row.repo_id
|
|
223
|
+
for row in session.query(APIKeyRepositoryModel)
|
|
224
|
+
.filter(APIKeyRepositoryModel.api_key_id == key_id)
|
|
225
|
+
.all()
|
|
226
|
+
]
|
|
227
|
+
finally:
|
|
228
|
+
session.close()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def list_api_keys_for_repositories(storage: StorageManager, repository_ids: set[int]) -> list[APIKeySummary]:
|
|
232
|
+
"""
|
|
233
|
+
Returns every API key that holds AT LEAST ONE grant among
|
|
234
|
+
`repository_ids`, ordered by id. Each returned `APIKeySummary`
|
|
235
|
+
carries its FULL grant list (see that class's own docstring for why
|
|
236
|
+
filtering it down is the caller's job, not this function's).
|
|
237
|
+
"""
|
|
238
|
+
if not repository_ids:
|
|
239
|
+
return []
|
|
240
|
+
session = storage.get_session()
|
|
241
|
+
try:
|
|
242
|
+
key_ids = {
|
|
243
|
+
row.api_key_id
|
|
244
|
+
for row in session.query(APIKeyRepositoryModel.api_key_id)
|
|
245
|
+
.filter(APIKeyRepositoryModel.repo_id.in_(repository_ids))
|
|
246
|
+
.all()
|
|
247
|
+
}
|
|
248
|
+
if not key_ids:
|
|
249
|
+
return []
|
|
250
|
+
keys = (
|
|
251
|
+
session.query(APIKeyModel)
|
|
252
|
+
.filter(APIKeyModel.id.in_(key_ids))
|
|
253
|
+
.order_by(APIKeyModel.id)
|
|
254
|
+
.all()
|
|
255
|
+
)
|
|
256
|
+
grants_by_key: dict[int, list[APIKeyGrant]] = {k.id: [] for k in keys}
|
|
257
|
+
for row in (
|
|
258
|
+
session.query(APIKeyRepositoryModel)
|
|
259
|
+
.filter(APIKeyRepositoryModel.api_key_id.in_(key_ids))
|
|
260
|
+
.all()
|
|
261
|
+
):
|
|
262
|
+
grants_by_key[row.api_key_id].append(APIKeyGrant(repository_id=row.repo_id, role=row.role))
|
|
263
|
+
return [
|
|
264
|
+
APIKeySummary(
|
|
265
|
+
id=k.id,
|
|
266
|
+
name=k.name,
|
|
267
|
+
key_prefix=k.key_prefix,
|
|
268
|
+
created_at=k.created_at,
|
|
269
|
+
revoked_at=k.revoked_at,
|
|
270
|
+
grants=grants_by_key[k.id],
|
|
271
|
+
)
|
|
272
|
+
for k in keys
|
|
273
|
+
]
|
|
274
|
+
finally:
|
|
275
|
+
session.close()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def register_repository(storage: StorageManager, repo_name: str, repo_url: str) -> tuple[int, bool]:
|
|
279
|
+
"""
|
|
280
|
+
Makes sure a repository row exists for `repo_url` and returns `(repository_id, created)`, granting NOTHING to
|
|
281
|
+
anyone. It is the same empty row indexing would create (no files, no symbols), found under any spelling of the
|
|
282
|
+
same directory, so a later index run reuses it. Used by `codepulse key create` so a key can be scoped to a folder
|
|
283
|
+
that has not been indexed yet: the first-run flow is "make a key for this folder, then index it with that key".
|
|
284
|
+
Contrast `create_repository_owned_by`, which also writes the creating key's admin grant.
|
|
285
|
+
"""
|
|
286
|
+
session = storage.get_session()
|
|
287
|
+
try:
|
|
288
|
+
existing = find_repository(session, repo_url)
|
|
289
|
+
if existing is not None:
|
|
290
|
+
return existing.id, False
|
|
291
|
+
repo = RepositoryModel(name=repo_name, url=canonical_repository_url(repo_url), file_count=0, symbol_count=0)
|
|
292
|
+
session.add(repo)
|
|
293
|
+
session.commit()
|
|
294
|
+
return repo.id, True
|
|
295
|
+
except IntegrityError:
|
|
296
|
+
# Lost a race to create the same url: use the row the other call made.
|
|
297
|
+
session.rollback()
|
|
298
|
+
existing = find_repository(session, repo_url)
|
|
299
|
+
if existing is None:
|
|
300
|
+
raise
|
|
301
|
+
return existing.id, False
|
|
302
|
+
finally:
|
|
303
|
+
session.close()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def create_repository_owned_by(
|
|
307
|
+
storage: StorageManager, repo_name: str, repo_url: str, api_key_id: int
|
|
308
|
+
) -> tuple[int, bool]:
|
|
309
|
+
"""
|
|
310
|
+
Creates the repository row for `repo_url` AND the creating key's `admin`
|
|
311
|
+
grant on it in ONE transaction, and returns `(repository_id, created)`.
|
|
312
|
+
|
|
313
|
+
Owner semantics (CODEPULSE_FUTURE_CHANGES.md item 18, user-approved): a
|
|
314
|
+
key that creates a new repository must be able to follow its own indexing
|
|
315
|
+
job and see the result, which needs a grant on it. Only the call that
|
|
316
|
+
actually CREATES the row gets the grant: if the repository already exists
|
|
317
|
+
(including when another request created it a moment earlier and this one
|
|
318
|
+
lost the race on the unique `url`), `created` is False and NO grant is
|
|
319
|
+
written, so a second key never inherits admin on someone else's
|
|
320
|
+
repository. Callers decide who may create (the `indexer`-somewhere rule
|
|
321
|
+
lives at the endpoint); this function only makes creation and ownership
|
|
322
|
+
atomic.
|
|
323
|
+
"""
|
|
324
|
+
session = storage.get_session()
|
|
325
|
+
try:
|
|
326
|
+
existing = find_repository(session, repo_url)
|
|
327
|
+
if existing is not None:
|
|
328
|
+
return existing.id, False
|
|
329
|
+
repo = RepositoryModel(name=repo_name, url=canonical_repository_url(repo_url), file_count=0, symbol_count=0)
|
|
330
|
+
session.add(repo)
|
|
331
|
+
session.flush()
|
|
332
|
+
session.add(APIKeyRepositoryModel(api_key_id=api_key_id, repo_id=repo.id, role="admin"))
|
|
333
|
+
session.commit()
|
|
334
|
+
return repo.id, True
|
|
335
|
+
except IntegrityError:
|
|
336
|
+
# Lost a race to create the same url: the other request owns it.
|
|
337
|
+
session.rollback()
|
|
338
|
+
existing = find_repository(session, repo_url)
|
|
339
|
+
if existing is None:
|
|
340
|
+
raise
|
|
341
|
+
return existing.id, False
|
|
342
|
+
finally:
|
|
343
|
+
session.close()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def revoke_api_key(storage: StorageManager, key_id: int) -> APIKeySummary | None:
|
|
347
|
+
"""
|
|
348
|
+
Sets `revoked_at` on the given key if it exists and is not already
|
|
349
|
+
revoked -- the mutating revoke() flow this module's own docstring
|
|
350
|
+
has disclosed as missing since Phase 1.1 (only the schema column
|
|
351
|
+
existed; `verify_api_key` already respects it defensively, so a
|
|
352
|
+
revoked key stops authenticating immediately once this runs).
|
|
353
|
+
Idempotent: calling this again on an already-revoked key leaves its
|
|
354
|
+
original `revoked_at` untouched rather than overwriting it with a
|
|
355
|
+
later timestamp. Returns the key's up-to-date `APIKeySummary`
|
|
356
|
+
(full, unfiltered grant list -- same caller responsibility as
|
|
357
|
+
`list_api_keys_for_repositories`), or `None` if no key with this id
|
|
358
|
+
exists.
|
|
359
|
+
"""
|
|
360
|
+
session = storage.get_session()
|
|
361
|
+
try:
|
|
362
|
+
api_key = session.query(APIKeyModel).filter(APIKeyModel.id == key_id).first()
|
|
363
|
+
if api_key is None:
|
|
364
|
+
return None
|
|
365
|
+
if api_key.revoked_at is None:
|
|
366
|
+
api_key.revoked_at = datetime.utcnow()
|
|
367
|
+
session.commit()
|
|
368
|
+
session.refresh(api_key)
|
|
369
|
+
grants = [
|
|
370
|
+
APIKeyGrant(repository_id=row.repo_id, role=row.role)
|
|
371
|
+
for row in session.query(APIKeyRepositoryModel)
|
|
372
|
+
.filter(APIKeyRepositoryModel.api_key_id == key_id)
|
|
373
|
+
.all()
|
|
374
|
+
]
|
|
375
|
+
return APIKeySummary(
|
|
376
|
+
id=api_key.id,
|
|
377
|
+
name=api_key.name,
|
|
378
|
+
key_prefix=api_key.key_prefix,
|
|
379
|
+
created_at=api_key.created_at,
|
|
380
|
+
revoked_at=api_key.revoked_at,
|
|
381
|
+
grants=grants,
|
|
382
|
+
)
|
|
383
|
+
finally:
|
|
384
|
+
session.close()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def verify_api_key(storage: StorageManager, raw_key: str) -> int | None:
|
|
388
|
+
"""
|
|
389
|
+
Looks up the id of the APIKeyModel row matching `raw_key`, if any and
|
|
390
|
+
only if it has not been revoked. Returns None for an unknown, wrong,
|
|
391
|
+
or revoked key.
|
|
392
|
+
|
|
393
|
+
This is a verification PRIMITIVE only -- proving the hash-and-lookup
|
|
394
|
+
mechanism genuinely works -- not a FastAPI dependency, not wired into
|
|
395
|
+
any endpoint or request path. That integration is Phase 1.2.
|
|
396
|
+
"""
|
|
397
|
+
session = storage.get_session()
|
|
398
|
+
try:
|
|
399
|
+
api_key = (
|
|
400
|
+
session.query(APIKeyModel).filter(APIKeyModel.key_hash == hash_api_key(raw_key)).first()
|
|
401
|
+
)
|
|
402
|
+
if api_key is None or api_key.revoked_at is not None:
|
|
403
|
+
return None
|
|
404
|
+
return api_key.id
|
|
405
|
+
finally:
|
|
406
|
+
session.close()
|