Batch certificate summary lookups
This commit is contained in:
@@ -39,7 +39,7 @@ from ..service import (
|
||||
save_key_once,
|
||||
search_text,
|
||||
serialize_certificate_detail,
|
||||
serialize_certificate_summary,
|
||||
serialize_certificate_summaries,
|
||||
serialize_revocation,
|
||||
serialize_template_summary,
|
||||
serialize_crl_detail,
|
||||
@@ -74,7 +74,7 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
||||
if vault is None:
|
||||
return
|
||||
rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id")
|
||||
await send_json(ctx, 200, {"results": [await serialize_certificate_summary(c) for c in rows]})
|
||||
await send_json(ctx, 200, {"results": await serialize_certificate_summaries(rows)})
|
||||
|
||||
@app.POST("/api/vaults/${vault_id}/certificates")
|
||||
@operation(summary="Issue a certificate", tags=["certificates"])
|
||||
|
||||
@@ -220,23 +220,27 @@ async def revoked_for(cert: Certificate) -> Optional[CertificateRevocation]:
|
||||
return await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex)
|
||||
|
||||
|
||||
async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]:
|
||||
revoked = await revoked_for(cert)
|
||||
def _certificate_summary(
|
||||
cert: Certificate,
|
||||
revoked: Optional[CertificateRevocation],
|
||||
has_private_key: bool,
|
||||
) -> Dict[str, Any]:
|
||||
parsed = cert_core.parse_certificate(bytes(cert.cert_der))
|
||||
return {
|
||||
"id": str(cert.id),
|
||||
"name": cert.name,
|
||||
"comment": cert.comment,
|
||||
"source": cert.source,
|
||||
"subject": name_core.one_line(cert_core.parse_certificate(bytes(cert.cert_der)).subject),
|
||||
"subject": name_core.one_line(parsed.subject),
|
||||
"issuer_id": str(cert.issuer_id) if cert.issuer_id else None,
|
||||
"issuer_name": name_core.one_line(cert_core.parse_certificate(bytes(cert.cert_der)).issuer),
|
||||
"issuer_name": name_core.one_line(parsed.issuer),
|
||||
"serial_hex": cert.serial_hex,
|
||||
"not_before": iso(cert.not_before),
|
||||
"not_after": iso(cert.not_after),
|
||||
"no_expiration": cert.no_expiration,
|
||||
"is_ca": cert.is_ca,
|
||||
"path_length": cert.path_length,
|
||||
"has_private_key": bool(cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()),
|
||||
"has_private_key": has_private_key,
|
||||
"revoked": revoked is not None,
|
||||
"revoked_at": iso(revoked.revoked_at) if revoked else None,
|
||||
"fingerprint_sha256": cert.fingerprint_sha256,
|
||||
@@ -247,6 +251,52 @@ async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]:
|
||||
revoked = await revoked_for(cert)
|
||||
has_private_key = bool(
|
||||
cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()
|
||||
)
|
||||
return _certificate_summary(cert, revoked, has_private_key)
|
||||
|
||||
|
||||
async def serialize_certificate_summaries(
|
||||
certificates: Sequence[Certificate],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Serialize a certificate list with constant-count revocation/key queries."""
|
||||
if not certificates:
|
||||
return []
|
||||
|
||||
issuer_ids = {cert.issuer_id for cert in certificates if cert.issuer_id is not None}
|
||||
serial_hexes = {cert.serial_hex for cert in certificates if cert.issuer_id is not None}
|
||||
revocations = (
|
||||
await CertificateRevocation.filter(
|
||||
ca_id__in=issuer_ids,
|
||||
serial_hex__in=serial_hexes,
|
||||
)
|
||||
if issuer_ids
|
||||
else []
|
||||
)
|
||||
revoked_by_certificate = {
|
||||
(revocation.ca_id, revocation.serial_hex): revocation
|
||||
for revocation in revocations
|
||||
}
|
||||
|
||||
key_ids = {cert.key_id for cert in certificates if cert.key_id is not None}
|
||||
private_key_ids = (
|
||||
set(await Key.filter(id__in=key_ids, has_private=True).values_list("id", flat=True))
|
||||
if key_ids
|
||||
else set()
|
||||
)
|
||||
return [
|
||||
_certificate_summary(
|
||||
cert,
|
||||
revoked_by_certificate.get((cert.issuer_id, cert.serial_hex)),
|
||||
cert.key_id in private_key_ids,
|
||||
)
|
||||
for cert in certificates
|
||||
]
|
||||
|
||||
|
||||
async def chain_for_certificate(cert: Certificate, vault: Vault) -> List[Certificate]:
|
||||
chain = [cert]
|
||||
current = cert
|
||||
@@ -1591,6 +1641,7 @@ __all__ = [
|
||||
"save_key",
|
||||
"save_key_once",
|
||||
"serialize_certificate_detail",
|
||||
"serialize_certificate_summaries",
|
||||
"serialize_certificate_summary",
|
||||
"serialize_crl_detail",
|
||||
"serialize_crl_summary",
|
||||
|
||||
@@ -604,6 +604,77 @@ class ExtendedRouteTests(unittest.TestCase):
|
||||
assert response.json()["reason"] == "superseded"
|
||||
|
||||
|
||||
class CertificateListTests(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_summaries_batch_revocation_and_private_key_queries(self) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca import service
|
||||
from kca.models import CertificateRevocation, Key
|
||||
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice"):
|
||||
vault_id = await _vault(client)
|
||||
url = f"/api/vaults/{vault_id}/certificates"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
CertificateRevocation,
|
||||
"filter",
|
||||
wraps=CertificateRevocation.filter,
|
||||
) as revocation_filter,
|
||||
patch.object(Key, "filter", wraps=Key.filter) as key_filter,
|
||||
):
|
||||
response = await client.get(url)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["results"] == []
|
||||
revocation_filter.assert_not_called()
|
||||
key_filter.assert_not_called()
|
||||
|
||||
ca_key = await _key(client, vault_id, "list ca")
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "List CA", ca=True)
|
||||
leaf_key = await _key(client, vault_id, "list leaf")
|
||||
leaf = await _cert(
|
||||
client,
|
||||
vault_id,
|
||||
leaf_key["id"],
|
||||
"List Leaf",
|
||||
signer_id=ca["id"],
|
||||
)
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
||||
json={"reason": "superseded"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
CertificateRevocation,
|
||||
"filter",
|
||||
wraps=CertificateRevocation.filter,
|
||||
) as revocation_filter,
|
||||
patch.object(Key, "filter", wraps=Key.filter) as key_filter,
|
||||
patch.object(
|
||||
service,
|
||||
"revoked_for",
|
||||
side_effect=AssertionError("per-certificate revocation query"),
|
||||
) as single_revocation,
|
||||
):
|
||||
response = await client.get(url)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
revocation_filter.assert_called_once()
|
||||
key_filter.assert_called_once()
|
||||
single_revocation.assert_not_called()
|
||||
summaries = {row["id"]: row for row in response.json()["results"]}
|
||||
assert summaries[ca["id"]]["revoked"] is False
|
||||
assert summaries[ca["id"]]["revoked_at"] is None
|
||||
assert summaries[ca["id"]]["has_private_key"] is True
|
||||
assert summaries[leaf["id"]]["revoked"] is True
|
||||
assert summaries[leaf["id"]]["revoked_at"] is not None
|
||||
assert summaries[leaf["id"]]["has_private_key"] is True
|
||||
|
||||
|
||||
class SerialRenewalTests(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_explicit_serial_must_be_unique_and_not_revoked(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user