Add server template apply modes, bulk revocation, explicit DN order and detail tabs

This commit is contained in:
2026-10-23 05:33:43 +00:00
parent 5eb7866f5b
commit 763268bd40
13 changed files with 1477 additions and 633 deletions
@@ -26,6 +26,7 @@ from ..service import (
chain_for_certificate,
delete_certificate_cascade,
generate_crl,
bulk_revoke_certificates,
issue_certificate,
load_private_key,
preview_certificate,
@@ -97,6 +98,25 @@ def register(app: KayaApp, kca: "Kca") -> None:
return
await send_json(ctx, 201, await serialize_certificate_detail(cert, vault))
@app.POST("/api/vaults/${vault_id}/certificates/bulk-revoke")
@operation(summary="Revoke several certificates", tags=["certificates"])
@require_auth(kca.oidc)
async def bulk_revoke(ctx: HttpContext, vault_id: str) -> None:
vault = await _vault(kca, ctx, vault_id)
if vault is None:
return
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
try:
result = await bulk_revoke_certificates(vault, body)
except (ValidationError, ValueError) as exc:
await send_error(ctx, 400, str(exc))
return
await send_json(ctx, 200, result)
@app.POST("/api/vaults/${vault_id}/certificates/preview")
@operation(summary="Preview/validate a certificate payload", tags=["certificates"])
@require_auth(kca.oidc)
@@ -73,6 +73,17 @@ def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict
if not (32 <= bits <= 160):
raise ValidationError("serial_len_bits must be between 32 and 160")
merged["serial_len_bits"] = bits
for key in ("explicit_dn", "mandatory_dn"):
entries = merged.get(key) or []
if not isinstance(entries, list):
raise ValidationError(f"{key} must be a list")
for entry in entries:
if not isinstance(entry, str) or not entry.strip():
raise ValidationError(f"{key} entries must be non-empty strings")
try:
name_core.resolve_oid(entry)
except ValueError as exc:
raise ValidationError(f"unknown subject attribute {entry!r}") from exc
return merged
@@ -965,6 +976,63 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
return row
async def bulk_revoke_certificates(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
"""Revoke several certificates in one request, reporting per-item failures."""
raw_ids = payload.get("certificate_ids") or payload.get("ids") or []
if not isinstance(raw_ids, list):
raise ValidationError("certificate_ids must be a list")
seen: set[str] = set()
ids: list[str] = []
for raw in raw_ids:
text = str(raw)
if text in seen:
continue
seen.add(text)
ids.append(text)
if not ids:
raise ValidationError("select at least one certificate")
if len(ids) > 200:
raise ValidationError("at most 200 certificates can be revoked at once")
reason = str(payload.get("reason") or "unspecified")
if reason not in {item["name"] for item in crl_core.CRL_REASONS}:
raise ValidationError("unknown revocation reason")
invalidity = payload.get("invalidity_date")
if payload.get("revoked_at"):
parse_datetime(payload["revoked_at"], "revoked_at")
if invalidity:
parse_datetime(invalidity, "invalidity_date")
revocation_payload = {
"reason": reason,
"revoked_at": payload.get("revoked_at"),
"invalidity_date": invalidity,
}
revoked: list[Dict[str, Any]] = []
skipped: list[Dict[str, Any]] = []
errors: list[Dict[str, Any]] = []
for raw_id in ids:
parsed_id = parse_uuid(raw_id)
if parsed_id is None:
errors.append({"id": raw_id, "error": "invalid certificate id"})
continue
cert = await Certificate.get_or_none(id=parsed_id, vault_id=vault.id)
if cert is None:
errors.append({"id": raw_id, "error": "certificate not found"})
continue
existing = None
if cert.issuer_id is not None:
existing = await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex)
if existing is not None:
skipped.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "reason": "already revoked"})
continue
try:
row = await revoke_certificate(vault, cert, revocation_payload)
except (ValidationError, ValueError) as exc:
errors.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "error": str(exc)})
continue
revoked.append({"id": str(cert.id), "serial_hex": cert.serial_hex, "revocation_id": str(row.id)})
return {"revoked": revoked, "skipped": skipped, "errors": errors}
async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]) -> Crl:
if not ca.is_ca:
raise ValidationError("the selected certificate is not a CA")
@@ -1323,6 +1391,7 @@ def _reason_from_bit(bit: int) -> str:
__all__ = [
"DEFAULT_VAULT_SETTINGS",
"ValidationError",
"bulk_revoke_certificates",
"chain_for_certificate",
"extension_search_text",
"delete_certificate_cascade",