tiny-ca-gateway 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.
@@ -0,0 +1,580 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator, Generator
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+ import uuid as _uuid_module
7
+
8
+ import aiofiles
9
+ from cryptography import x509
10
+ from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
11
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
12
+ from fastapi import Path as FPath
13
+ from starlette.responses import FileResponse, StreamingResponse
14
+ from tiny_ca.models.certificate import CAConfig, CertificateDetails
15
+
16
+ from tiny_ca_gateway.core import (
17
+ REASON_MAP,
18
+ CertListItem,
19
+ ChainResponse,
20
+ CosignRequest,
21
+ CosignResponse,
22
+ CreateRootCaResponse,
23
+ CRLRefreshResponse,
24
+ CRLVerifyRequest,
25
+ DeleteResponse,
26
+ ExpiringResponse,
27
+ ExportP12Request,
28
+ IntermediateCARequest,
29
+ IssueCertRequest,
30
+ IssueCertResponse,
31
+ MaintenanceResponse,
32
+ RenewRequest,
33
+ RenewResponse,
34
+ RevokeRequest,
35
+ RevokeResponse,
36
+ RotateResponse,
37
+ Routes,
38
+ StatusResponse,
39
+ VerifyRequest,
40
+ VerifyResponse,
41
+ build_client_config,
42
+ get_artifact_path,
43
+ record_to_item,
44
+ )
45
+ from tiny_ca_gateway.core.schemas import ArtifactType, CRLVerifyResponse
46
+ from tiny_ca_gateway.core.summary import RoutesSummary
47
+ from tiny_ca_gateway.fastapi.api.v1.utils import (
48
+ load_pem_cert,
49
+ load_pem_crl,
50
+ require_factory,
51
+ )
52
+ from tiny_ca_gateway.fastapi.auth.verify import verify_token
53
+ from tiny_ca_gateway.fastapi.lifespan.manager import FastAPILifespanManager
54
+ from tiny_ca_gateway.models import API_SETTINGS
55
+
56
+ router = APIRouter(prefix="/ca", tags=["ca"])
57
+
58
+
59
+ # ===========================================================================
60
+ # Public
61
+ # ===========================================================================
62
+
63
+
64
+ @router.get(Routes.GET_PUBLIC_CERT, summary=RoutesSummary.GET_PUBLIC_CERT)
65
+ async def get_public_cert() -> FileResponse:
66
+ from pathlib import Path as P
67
+
68
+ cert_path = P(API_SETTINGS.path_to_ca_cer)
69
+ if not cert_path.exists():
70
+ from fastapi import HTTPException
71
+
72
+ raise HTTPException(
73
+ 404, "CA certificate not found — bootstrap first via POST /root"
74
+ )
75
+ return FileResponse(
76
+ cert_path, media_type="application/x-pem-file", filename="ca.pem"
77
+ )
78
+
79
+
80
+ @router.get(
81
+ Routes.BASE_CRL,
82
+ summary=RoutesSummary.BASE_CRL,
83
+ responses={
84
+ 200: {"content": {"application/pkix-crl": {}, "application/x-pem-file": {}}},
85
+ },
86
+ )
87
+ async def crl_route(pem: bool = False) -> Response:
88
+ from pathlib import Path as P
89
+
90
+ crl_path = P(API_SETTINGS.path_to_crl)
91
+ if not crl_path.exists():
92
+ mgr = require_factory()
93
+ try:
94
+ await mgr.generate_crl()
95
+ except Exception as exc:
96
+ raise HTTPException(500, f"Failed to generate CRL: {exc}") from exc
97
+ if not crl_path.exists():
98
+ raise HTTPException(500, "CRL file still not found after generation attempt")
99
+ async with aiofiles.open(crl_path, "rb") as fh:
100
+ content = await fh.read()
101
+ if pem:
102
+ if not content.startswith(b"-----"):
103
+ crl_obj = x509.load_der_x509_crl(content)
104
+ content = crl_obj.public_bytes(Encoding.PEM)
105
+ return Response(
106
+ content,
107
+ media_type="application/x-pem-file",
108
+ headers={"Content-Disposition": 'attachment; filename="ca.crl.pem"'},
109
+ )
110
+ return Response(
111
+ content,
112
+ media_type="application/pkix-crl",
113
+ headers={
114
+ "Content-Disposition": 'attachment; filename="ca.crl"',
115
+ "Cache-Control": "max-age=3600",
116
+ },
117
+ )
118
+
119
+
120
+ # ===========================================================================
121
+ # List / search
122
+ # ===========================================================================
123
+
124
+
125
+ @router.get(
126
+ Routes.GET_LIST_CERTS,
127
+ summary=RoutesSummary.GET_LIST_CERTS,
128
+ response_model=list[CertListItem],
129
+ )
130
+ async def list_certificates(
131
+ token: str = Depends(verify_token),
132
+ status: str | None = Query(default=None),
133
+ key_type: str | None = Query(default=None),
134
+ limit: int = Query(default=100, ge=1, le=1000),
135
+ offset: int = Query(default=0, ge=0),
136
+ ) -> list[CertListItem]:
137
+ mgr = require_factory()
138
+ records = await mgr.list_certificates(
139
+ status=status, key_type=key_type, limit=limit, offset=offset
140
+ )
141
+ return [record_to_item(r) for r in records]
142
+
143
+
144
+ @router.get(
145
+ Routes.GET_LIST_EXPIRING_CERTS,
146
+ summary=RoutesSummary.GET_LIST_EXPIRING_CERTS,
147
+ response_model=ExpiringResponse,
148
+ )
149
+ async def get_expiring(
150
+ token: str = Depends(verify_token),
151
+ within_days: int = Query(default=30, ge=1, le=365),
152
+ ) -> ExpiringResponse:
153
+ mgr = require_factory()
154
+ records = await mgr.get_expiring_soon(within_days=within_days)
155
+ return ExpiringResponse(
156
+ within_days=within_days,
157
+ count=len(records),
158
+ certificates=[record_to_item(r) for r in records],
159
+ )
160
+
161
+
162
+ # ===========================================================================
163
+ # CA bootstrap / issuance
164
+ # ===========================================================================
165
+
166
+
167
+ @router.post(
168
+ Routes.CA_ROOT, summary=RoutesSummary.CA_ROOT, response_model=CreateRootCaResponse
169
+ )
170
+ async def create_root_ca(
171
+ token: str = Depends(verify_token),
172
+ payload: CAConfig = Body(),
173
+ ) -> CreateRootCaResponse:
174
+
175
+ lm = FastAPILifespanManager()
176
+ try:
177
+ await lm.rebuild_root_ca_pair(ca_config=payload)
178
+ except Exception as exc:
179
+ raise HTTPException(409, str(exc)) from exc
180
+ try:
181
+ await lm.rebuild_manager()
182
+ except Exception as exc:
183
+ lm.logger.error("Factory reload failed: %s", exc)
184
+ cert_obj = x509.load_pem_x509_certificate(
185
+ Path(API_SETTINGS.path_to_ca_cer).read_bytes()
186
+ )
187
+ return CreateRootCaResponse(
188
+ ca_id="",
189
+ expires_at=cert_obj.not_valid_after_utc.strftime(API_SETTINGS.datetime_fmt),
190
+ )
191
+
192
+
193
+ @router.post(
194
+ Routes.CA_INTERMEDIATE,
195
+ summary=RoutesSummary.CA_INTERMEDIATE,
196
+ response_model=IssueCertResponse,
197
+ )
198
+ async def issue_intermediate_ca(
199
+ token: str = Depends(verify_token),
200
+ payload: IntermediateCARequest = Body(),
201
+ ) -> IssueCertResponse:
202
+ from fastapi import HTTPException
203
+
204
+ mgr = require_factory()
205
+ _uuid = str(_uuid_module.uuid4())
206
+ try:
207
+ cert, _ = await mgr.issue_intermediate_ca(
208
+ common_name=payload.common_name,
209
+ key_size=payload.key_size,
210
+ days_valid=payload.days_valid,
211
+ path_length=payload.path_length,
212
+ organization=payload.organization,
213
+ country=payload.country,
214
+ uuid_str=_uuid,
215
+ )
216
+ except Exception as exc:
217
+ raise HTTPException(422, str(exc)) from exc
218
+ return IssueCertResponse(
219
+ uuid=_uuid,
220
+ serial_number=cert.serial_number,
221
+ common_name=payload.common_name,
222
+ not_valid_before=cert.not_valid_before_utc,
223
+ not_valid_after=cert.not_valid_after_utc,
224
+ )
225
+
226
+
227
+ @router.post(
228
+ Routes.ISSUE, summary=RoutesSummary.ISSUE, response_model=IssueCertResponse
229
+ )
230
+ async def issue_certificate(
231
+ token: str = Depends(verify_token),
232
+ payload: IssueCertRequest = Body(),
233
+ ) -> IssueCertResponse:
234
+ from fastapi import HTTPException
235
+
236
+ mgr = require_factory()
237
+ _uuid = str(_uuid_module.uuid4())
238
+ try:
239
+ if payload.key_size and (payload.key_size < 2048 or payload.key_size > 4096):
240
+ raise HTTPException(
241
+ 400, f"Invalid key_size: {payload.key_size}. Must be 2048-4096."
242
+ )
243
+
244
+ cert, _, _ = await mgr.issue_certificate(
245
+ config=build_client_config(payload),
246
+ uuid_str=_uuid,
247
+ is_overwrite=payload.is_overwrite,
248
+ )
249
+ except HTTPException:
250
+ raise
251
+ except ValueError as exc:
252
+ if "key" in str(exc).lower():
253
+ raise HTTPException(400, str(exc)) from exc
254
+ raise HTTPException(409, str(exc)) from exc
255
+ except Exception as exc:
256
+ raise HTTPException(409, str(exc)) from exc
257
+ return IssueCertResponse(
258
+ uuid=_uuid,
259
+ serial_number=cert.serial_number,
260
+ common_name=payload.common_name,
261
+ not_valid_before=cert.not_valid_before_utc,
262
+ not_valid_after=cert.not_valid_after_utc,
263
+ )
264
+
265
+
266
+ @router.post(
267
+ Routes.MAINTENANCE_EXPIRE,
268
+ summary=RoutesSummary.MAINTENANCE_EXPIRE,
269
+ response_model=MaintenanceResponse,
270
+ )
271
+ async def mark_expired(token: str = Depends(verify_token)) -> MaintenanceResponse:
272
+ return MaintenanceResponse(
273
+ updated=await require_factory().refresh_expired_statuses()
274
+ )
275
+
276
+
277
+ # ===========================================================================
278
+ # CRL
279
+ # ===========================================================================
280
+
281
+
282
+ @router.post(
283
+ Routes.CRL_REFRESH,
284
+ response_model=CRLRefreshResponse,
285
+ summary=RoutesSummary.CRL_REFRESH,
286
+ )
287
+ async def refresh_crl(token: str = Depends(verify_token)) -> CRLRefreshResponse:
288
+
289
+ try:
290
+ crl = await require_factory().generate_crl()
291
+ except Exception as exc:
292
+ raise HTTPException(500, str(exc)) from exc
293
+ return CRLRefreshResponse(next_update=crl.next_update_utc)
294
+
295
+
296
+ @router.post(Routes.CRL_VERIFY, summary=RoutesSummary.CRL_VERIFY)
297
+ async def verify_crl(
298
+ token: str = Depends(verify_token), payload: CRLVerifyRequest = Body()
299
+ ) -> CRLVerifyResponse:
300
+ crl = load_pem_crl(payload.pem)
301
+ try:
302
+ await require_factory().verify_crl(crl=crl)
303
+
304
+ return CRLVerifyResponse(valid=True, next_update=str(crl.next_update_utc))
305
+ except Exception as exc:
306
+ return CRLVerifyResponse(valid=False, detail=str(exc))
307
+
308
+
309
+ # ===========================================================================
310
+ # Verification / cosign / export
311
+ # ===========================================================================
312
+
313
+
314
+ @router.post(
315
+ Routes.VERIFY,
316
+ response_model=VerifyResponse,
317
+ summary=RoutesSummary.VERIFY,
318
+ )
319
+ async def verify_certificate(
320
+ token: str = Depends(verify_token), payload: VerifyRequest = Body()
321
+ ) -> VerifyResponse:
322
+ cert = load_pem_cert(payload.pem)
323
+ try:
324
+ result = await require_factory().verify_certificate(cert=cert)
325
+ if isinstance(result, tuple):
326
+ valid, reason = result
327
+ if valid:
328
+ return VerifyResponse(valid=True)
329
+ else:
330
+ return VerifyResponse(
331
+ valid=False, detail=reason or "Verification failed"
332
+ )
333
+ return VerifyResponse(valid=True)
334
+ except Exception as exc:
335
+ return VerifyResponse(valid=False, detail=str(exc))
336
+
337
+
338
+ @router.post(
339
+ Routes.COSIGN,
340
+ response_model=CosignResponse,
341
+ summary=RoutesSummary.COSIGN,
342
+ )
343
+ async def cosign_certificate(
344
+ token: str = Depends(verify_token), payload: CosignRequest = Body()
345
+ ) -> CosignResponse:
346
+ from fastapi import HTTPException
347
+
348
+ cert = load_pem_cert(payload.pem)
349
+ try:
350
+ cosigned = await require_factory().cosign_certificate(
351
+ cert=cert,
352
+ days_valid=payload.days_valid,
353
+ valid_from=payload.valid_from,
354
+ )
355
+ except Exception as exc:
356
+ raise HTTPException(422, str(exc)) from exc
357
+ return CosignResponse(
358
+ serial_number=cosigned.serial_number,
359
+ not_valid_before=cosigned.not_valid_before_utc,
360
+ not_valid_after=cosigned.not_valid_after_utc,
361
+ pem=cosigned.public_bytes(Encoding.PEM).decode(),
362
+ )
363
+
364
+
365
+ @router.post(Routes.EXPORT, summary=RoutesSummary.EXPORT)
366
+ async def export_pkcs12(
367
+ token: str = Depends(verify_token),
368
+ serial: int = FPath(),
369
+ payload: ExportP12Request = Body(default=ExportP12Request()),
370
+ ) -> Response:
371
+ from fastapi import HTTPException
372
+
373
+ mgr = require_factory()
374
+ record = await mgr._db.get_by_serial(serial=serial)
375
+ if record is None:
376
+ raise HTTPException(404, f"Certificate serial={serial} not found")
377
+ if not record.uuid:
378
+ raise HTTPException(404, "No storage UUID for this certificate")
379
+ key_path = await get_artifact_path(record.uuid, "key")
380
+ if key_path is None:
381
+ raise HTTPException(404, "Private key not found in storage")
382
+ async with aiofiles.open(key_path, "rb") as fh:
383
+ private_key = load_pem_private_key(await fh.read(), password=None)
384
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
385
+ password = payload.password.encode() if payload.password else None
386
+ p12_bytes = await mgr.export_pkcs12(
387
+ cert=cert, private_key=private_key, password=password
388
+ )
389
+ filename = (record.common_name or str(serial)).replace(" ", "_")
390
+ return Response(
391
+ p12_bytes,
392
+ media_type="application/x-pkcs12",
393
+ headers={"Content-Disposition": f'attachment; filename="{filename}.p12"'},
394
+ )
395
+
396
+
397
+ # ===========================================================================
398
+ # Mutations
399
+ # ===========================================================================
400
+
401
+
402
+ @router.patch(
403
+ Routes.REVOKE, response_model=RevokeResponse, summary=RoutesSummary.REVOKE
404
+ )
405
+ async def revoke_certificate(
406
+ token: str = Depends(verify_token), payload: RevokeRequest = Body()
407
+ ) -> RevokeResponse:
408
+ from fastapi import HTTPException
409
+
410
+ reason = REASON_MAP.get(payload.reason, x509.ReasonFlags.unspecified)
411
+ ok = await require_factory().revoke_certificate(
412
+ serial=payload.serial_number, reason=reason
413
+ )
414
+ if not ok:
415
+ raise HTTPException(
416
+ 404, f"No valid certificate with serial={payload.serial_number}"
417
+ )
418
+ return RevokeResponse(revoked=True, serial_number=payload.serial_number)
419
+
420
+
421
+ @router.post(
422
+ Routes.ROTATE,
423
+ response_model=RotateResponse,
424
+ summary=RoutesSummary.ROTATE,
425
+ )
426
+ async def rotate_certificate(
427
+ token: str = Depends(verify_token),
428
+ serial: int = FPath(),
429
+ payload: IssueCertRequest = Body(),
430
+ ) -> RotateResponse:
431
+ from fastapi import HTTPException
432
+
433
+ _uuid = str(_uuid_module.uuid4())
434
+ try:
435
+ new_cert, _, _ = await require_factory().rotate_certificate(
436
+ serial=serial, config=build_client_config(payload)
437
+ )
438
+ except Exception as exc:
439
+ raise HTTPException(404, str(exc)) from exc
440
+ return RotateResponse(
441
+ uuid=_uuid,
442
+ serial_number=new_cert.serial_number,
443
+ common_name=payload.common_name,
444
+ not_valid_before=new_cert.not_valid_before_utc,
445
+ not_valid_after=new_cert.not_valid_after_utc,
446
+ )
447
+
448
+
449
+ @router.post(
450
+ Routes.RENEW,
451
+ response_model=RenewResponse,
452
+ summary=RoutesSummary.RENEW,
453
+ )
454
+ async def renew_certificate(
455
+ token: str = Depends(verify_token),
456
+ serial: int = FPath(),
457
+ payload: RenewRequest = Body(),
458
+ ) -> RenewResponse:
459
+ from fastapi import HTTPException
460
+
461
+ try:
462
+ renewed = await require_factory().renew_certificate(
463
+ serial=serial, days_valid=payload.days_valid
464
+ )
465
+ except Exception as exc:
466
+ raise HTTPException(404, str(exc)) from exc
467
+ return RenewResponse(
468
+ old_serial=serial,
469
+ new_serial=renewed.serial_number,
470
+ not_valid_after=renewed.not_valid_after_utc,
471
+ )
472
+
473
+
474
+ @router.delete(
475
+ Routes.DELETE,
476
+ response_model=DeleteResponse,
477
+ summary=RoutesSummary.DELETE,
478
+ )
479
+ async def delete_certificate(
480
+ token: str = Depends(verify_token), serial: int = FPath()
481
+ ) -> DeleteResponse:
482
+ from fastapi import HTTPException
483
+
484
+ if not await require_factory().delete_certificate(serial=serial):
485
+ raise HTTPException(404, f"Certificate serial={serial} not found")
486
+ return DeleteResponse(deleted=True, serial=serial)
487
+
488
+
489
+ # ===========================================================================
490
+ # Inspection
491
+ # ===========================================================================
492
+
493
+
494
+ @router.get(
495
+ Routes.STATUS,
496
+ response_model=StatusResponse,
497
+ summary=RoutesSummary.STATUS,
498
+ )
499
+ async def get_status(
500
+ token: str = Depends(verify_token), serial: int = FPath()
501
+ ) -> StatusResponse:
502
+ status = await require_factory().get_certificate_status(serial=serial)
503
+ return StatusResponse(serial=serial, status=str(status))
504
+
505
+
506
+ @router.get(Routes.INSPECT, summary=RoutesSummary.INSPECT)
507
+ async def inspect_certificate(
508
+ token: str = Depends(verify_token), serial: int = FPath()
509
+ ) -> CertificateDetails:
510
+
511
+ mgr = require_factory()
512
+ record = await mgr._db.get_by_serial(serial=serial)
513
+ if record is None:
514
+ raise HTTPException(404, f"Certificate serial={serial} not found")
515
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
516
+ return await mgr.inspect_certificate(cert=cert)
517
+
518
+
519
+ @router.get(
520
+ Routes.CHAIN,
521
+ response_model=ChainResponse,
522
+ summary=RoutesSummary.CHAIN,
523
+ )
524
+ async def get_cert_chain(
525
+ token: str = Depends(verify_token), serial: int = FPath()
526
+ ) -> ChainResponse:
527
+
528
+ mgr = require_factory()
529
+ record = await mgr._db.get_by_serial(serial=serial)
530
+ if record is None:
531
+ raise HTTPException(404, f"Certificate serial={serial} not found")
532
+ cert = x509.load_pem_x509_certificate(record.certificate_pem.encode())
533
+ chain = await mgr.get_cert_chain(cert=cert)
534
+ return ChainResponse(
535
+ serial=serial,
536
+ chain_length=len(chain),
537
+ chain=[c.public_bytes(Encoding.PEM).decode() for c in chain],
538
+ )
539
+
540
+
541
+ # ===========================================================================
542
+ # Downloads
543
+ # ===========================================================================
544
+
545
+
546
+ @router.get(Routes.DOWNLOAD_STREAM, summary=RoutesSummary.DOWNLOAD_STREAM)
547
+ async def stream_artefact(
548
+ token: str = Depends(verify_token),
549
+ uuid_certificate: str = FPath(),
550
+ object_type: Literal["pem", "key", "csr"] = Query(),
551
+ ) -> StreamingResponse:
552
+
553
+ path = await get_artifact_path(uuid_certificate, object_type)
554
+ if path is None:
555
+ raise HTTPException(404, "Artefact not found")
556
+
557
+ async def _iter(p: Path, chunk: int = 1024 * 1024) -> AsyncGenerator[bytes, None]:
558
+ async with aiofiles.open(p, "rb") as fh:
559
+ while data := await fh.read(chunk):
560
+ yield data
561
+
562
+ return StreamingResponse(
563
+ _iter(path),
564
+ media_type="application/x-pem-file",
565
+ headers={"Content-Disposition": f'attachment; filename="{path.name}"'},
566
+ )
567
+
568
+
569
+ @router.get(Routes.DOWNLOAD, summary=RoutesSummary.DOWNLOAD)
570
+ async def download_artefact(
571
+ token: str = Depends(verify_token),
572
+ uuid_certificate: str = FPath(),
573
+ object_type: ArtifactType = Query(),
574
+ ) -> FileResponse:
575
+ from fastapi import HTTPException
576
+
577
+ path = await get_artifact_path(uuid_certificate, object_type)
578
+ if path is None:
579
+ raise HTTPException(404, "Artefact not found")
580
+ return FileResponse(path, media_type="application/x-pem-file", filename=path.name)
@@ -0,0 +1,87 @@
1
+ from pathlib import Path
2
+ from typing import Literal
3
+
4
+ from cryptography import x509
5
+ from fastapi import HTTPException
6
+ from tiny_ca.managers.async_lifecycle_manager import AsyncCertLifecycleManager
7
+
8
+ from tiny_ca_gateway.core import CertListItem
9
+ from tiny_ca_gateway.fastapi.lifespan import FastAPILifespanManager
10
+ from tiny_ca_gateway.models import API_SETTINGS
11
+
12
+ ArtifactType = Literal["pem", "key", "csr"]
13
+
14
+
15
+ def _record_to_item(r: object) -> CertListItem:
16
+ return CertListItem(
17
+ serial_number=r.serial_number, # type: ignore[attr-defined]
18
+ common_name=r.common_name, # type: ignore[attr-defined]
19
+ status=r.status, # type: ignore[attr-defined]
20
+ key_type=r.key_type, # type: ignore[attr-defined]
21
+ not_valid_after=r.not_valid_after, # type: ignore[attr-defined]
22
+ uuid=r.uuid, # type: ignore[attr-defined]
23
+ )
24
+
25
+
26
+ def require_factory() -> AsyncCertLifecycleManager:
27
+ """Return the singleton manager; raise 503 if the factory (CA) is not loaded."""
28
+ mgr: AsyncCertLifecycleManager = FastAPILifespanManager().manager
29
+ if mgr.factory is None:
30
+ raise HTTPException(
31
+ 503,
32
+ "CA not initialised — call POST /v1/ca/root to bootstrap the certificate authority.",
33
+ )
34
+ return mgr
35
+
36
+
37
+ async def get_path_to_ca_file_if_exists(
38
+ uuid: str,
39
+ object_type: ArtifactType,
40
+ is_ca: bool = True,
41
+ ) -> Path | None:
42
+ """
43
+ Return the path to ``<base_folder>/<uuid>/<any_name>.<object_type>``
44
+ or ``None`` if it does not exist.
45
+
46
+ tiny_ca stores files as ``<common_name>.<ext>`` inside a UUID folder,
47
+ so we scan the directory for any file with the matching extension
48
+ rather than assuming a fixed filename.
49
+ """
50
+
51
+ folder = Path(API_SETTINGS.path_to_certificates_folder) / uuid
52
+ if not folder.is_dir():
53
+ print("not dir")
54
+ return None
55
+
56
+ extension = f".{object_type}"
57
+ for f in folder.iterdir():
58
+ print(f)
59
+ if f.is_file() and f.suffix == extension:
60
+ return f
61
+
62
+ return None
63
+
64
+
65
+ async def get_path_to_service_file_if_exists(
66
+ uuid: str,
67
+ extension: ArtifactType,
68
+ ) -> Path | None:
69
+ """
70
+ Same as above but scans under ``path_to_certificates_folder`` directly
71
+ (services live in the same base folder with their own UUID sub-dirs).
72
+ """
73
+ return await get_path_to_ca_file_if_exists(uuid, extension)
74
+
75
+
76
+ def load_pem_crl(pem: str) -> x509.CertificateRevocationList:
77
+ try:
78
+ return x509.load_pem_x509_crl(pem.encode())
79
+ except Exception as exc:
80
+ raise HTTPException(422, f"Invalid PEM CRL: {exc}") from exc
81
+
82
+
83
+ def load_pem_cert(pem: str) -> x509.Certificate:
84
+ try:
85
+ return x509.load_pem_x509_certificate(pem.encode())
86
+ except Exception as exc:
87
+ raise HTTPException(422, f"Invalid PEM certificate: {exc}") from exc
File without changes