Add S3 OCSP status publishing and standalone Rust Axum responder
This commit is contained in:
@@ -51,6 +51,18 @@ OTEL_EXCLUDED_PATHS=/api/health
|
||||
#OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000
|
||||
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
|
||||
|
||||
# --- OCSP status publisher (optional) --------------------------------------
|
||||
# When a bucket is configured, KCA publishes one full JSON status snapshot
|
||||
# per CA. The standalone OCSP responder reads these objects. Uses the normal
|
||||
# AWS credential chain (AWS_ACCESS_KEY_ID /
|
||||
# AWS_SECRET_ACCESS_KEY, role, instance profile, ...).
|
||||
#KCA_OCSP_S3_BUCKET=kca-ocsp
|
||||
KCA_OCSP_S3_PREFIX=ocsp
|
||||
#KCA_OCSP_S3_REGION=eu-central-1
|
||||
#KCA_OCSP_S3_ENDPOINT=http://localhost:9000
|
||||
KCA_OCSP_S3_FORCE_PATH_STYLE=false
|
||||
KCA_OCSP_PUBLISH_INTERVAL_SECONDS=1
|
||||
|
||||
# --- CORS (optional) -------------------------------------------------------
|
||||
#CORS_ALLOW_ORIGINS=
|
||||
#CORS_ALLOW_ORIGIN_REGEX=
|
||||
|
||||
+2
-2
@@ -46,11 +46,11 @@ COPY server/uv.lock server/pyproject.toml ./
|
||||
COPY server/packages/kca-core/pyproject.toml packages/kca-core/
|
||||
COPY server/packages/kca-app/pyproject.toml packages/kca-app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-workspace --package kca-app --extra otel --no-dev
|
||||
uv sync --frozen --no-install-workspace --package kca-app --extra otel --extra s3 --no-dev
|
||||
|
||||
COPY server/packages/ ./packages/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-editable --package kca-app --extra otel --no-dev \
|
||||
uv sync --locked --no-editable --package kca-app --extra otel --extra s3 --no-dev \
|
||||
--reinstall-package kca-core --reinstall-package kca-app
|
||||
|
||||
COPY server/migrations ./migrations
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
|
||||
"ca_id" UUID NOT NULL PRIMARY KEY,
|
||||
"dirty" BOOL NOT NULL,
|
||||
"published_at" TIMESTAMPTZ,
|
||||
"last_error" TEXT,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ocsp_snapsh_dirty_a8d440" ON "ocsp_snapshot" ("dirty");
|
||||
COMMENT ON TABLE "ocsp_snapshot" IS 'Publication state for one CA''s OCSP status JSON object.';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "ocsp_snapshot";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"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"
|
||||
)
|
||||
@@ -31,6 +31,9 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
s3 = [
|
||||
"boto3>=1.34",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
|
||||
@@ -20,6 +20,7 @@ from redis.asyncio import Redis
|
||||
from .config import Settings, settings
|
||||
from .logging_config import configure_logging
|
||||
from .mixin import Kca, KcaMixin
|
||||
from .ocsp import publisher_mixin_from_settings
|
||||
from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI
|
||||
from .telemetry import (
|
||||
CountableSessionStore,
|
||||
@@ -143,6 +144,9 @@ mixins: list[KayaMixin] = [
|
||||
openapi_mixin,
|
||||
KcaMixin(kca),
|
||||
]
|
||||
ocsp_publisher = publisher_mixin_from_settings(settings, tortoise_mixin.ensure_context)
|
||||
if ocsp_publisher is not None:
|
||||
mixins.append(ocsp_publisher)
|
||||
|
||||
telemetry = telemetry_from_settings(settings, session_store)
|
||||
if telemetry is not None:
|
||||
|
||||
@@ -185,6 +185,12 @@ class Settings:
|
||||
otel_excluded_paths: Tuple[str, ...]
|
||||
otel_metric_export_interval_millis: int
|
||||
otel_metrics_poll_interval_seconds: int
|
||||
ocsp_s3_bucket: Optional[str]
|
||||
ocsp_s3_prefix: str
|
||||
ocsp_s3_region: Optional[str]
|
||||
ocsp_s3_endpoint: Optional[str]
|
||||
ocsp_s3_force_path_style: bool
|
||||
ocsp_publish_interval_seconds: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
@@ -231,6 +237,14 @@ class Settings:
|
||||
otel_metrics_poll_interval_seconds=int(
|
||||
_env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15")
|
||||
),
|
||||
ocsp_s3_bucket=os.environ.get("KCA_OCSP_S3_BUCKET") or None,
|
||||
ocsp_s3_prefix=_env("KCA_OCSP_S3_PREFIX", "ocsp").strip("/"),
|
||||
ocsp_s3_region=os.environ.get("KCA_OCSP_S3_REGION") or None,
|
||||
ocsp_s3_endpoint=os.environ.get("KCA_OCSP_S3_ENDPOINT") or None,
|
||||
ocsp_s3_force_path_style=_env_bool("KCA_OCSP_S3_FORCE_PATH_STYLE"),
|
||||
ocsp_publish_interval_seconds=max(
|
||||
1, int(_env("KCA_OCSP_PUBLISH_INTERVAL_SECONDS", "1"))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -298,6 +298,19 @@ class Crl(Model):
|
||||
return f"Crl(name={self.name!r}, number={self.crl_number})"
|
||||
|
||||
|
||||
class OcspSnapshot(Model):
|
||||
"""Publication state for one CA's OCSP status JSON object."""
|
||||
|
||||
ca_id = fields.UUIDField(pk=True)
|
||||
dirty = fields.BooleanField(default=True, db_index=True)
|
||||
published_at = fields.DatetimeField(null=True)
|
||||
last_error = fields.TextField(null=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "ocsp_snapshot"
|
||||
|
||||
|
||||
class Template(Model):
|
||||
"""An XCA-style template: subject + extension/validity defaults."""
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Per-CA OCSP status snapshots and S3 publication.
|
||||
|
||||
KCA is the source of truth for certificate status. This module builds a
|
||||
compact JSON document per CA and uploads it to an S3-compatible bucket so
|
||||
that the standalone OCSP responder never needs database access or a CA
|
||||
private key.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
|
||||
from .config import Settings, settings
|
||||
from .models import Certificate, CertificateRevocation, OcspSnapshot, Vault
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA = "kca-ocsp-status/v1"
|
||||
|
||||
|
||||
def _iso(value: Optional[datetime]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def object_key(ca_fingerprint_sha256: str) -> str:
|
||||
"""The S3 key for one CA; the responder derives the same key."""
|
||||
prefix = settings.ocsp_s3_prefix.strip("/")
|
||||
name = f"{ca_fingerprint_sha256.lower()}.json"
|
||||
return f"{prefix}/{name}" if prefix else name
|
||||
|
||||
|
||||
async def snapshot_for_ca(ca: Certificate) -> Dict[str, Any]:
|
||||
"""Build the full OCSP status snapshot for certificates issued by ``ca``."""
|
||||
certs = await Certificate.filter(vault_id=ca.vault_id, issuer_id=ca.id).order_by("serial_hex") # type: ignore[attr-defined]
|
||||
revocations = {
|
||||
row.serial_hex.lower(): row
|
||||
for row in await CertificateRevocation.filter(ca_id=ca.id)
|
||||
}
|
||||
entries = []
|
||||
for cert in certs:
|
||||
revocation = revocations.get(cert.serial_hex.lower())
|
||||
entry: Dict[str, Any] = {
|
||||
"serial_hex": cert.serial_hex.lower(),
|
||||
"status": "revoked" if revocation is not None else "valid",
|
||||
"not_after": _iso(cert.not_after),
|
||||
}
|
||||
if revocation is not None:
|
||||
entry.update(
|
||||
{
|
||||
"revoked_at": _iso(revocation.revoked_at),
|
||||
"reason": revocation.reason,
|
||||
"invalidity_date": _iso(revocation.invalidity_date),
|
||||
}
|
||||
)
|
||||
entries.append(entry)
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"issuer": {
|
||||
"id": str(ca.id),
|
||||
"sha256": ca.fingerprint_sha256.lower(),
|
||||
"subject": cert_subject(ca),
|
||||
},
|
||||
"generated_at": _iso(datetime.now(timezone.utc)),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def cert_subject(ca: Certificate) -> str:
|
||||
from kca.core import certificates as cert_core
|
||||
|
||||
return cert_core.parse_certificate(bytes(ca.cert_der)).subject.rfc4514_string()
|
||||
|
||||
|
||||
def _s3_client() -> Any:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"region_name": settings.ocsp_s3_region,
|
||||
"endpoint_url": settings.ocsp_s3_endpoint,
|
||||
}
|
||||
if settings.ocsp_s3_force_path_style:
|
||||
kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||
# boto3's create_client rejects an explicit None region/endpoint in some
|
||||
# versions, so only pass values that are set.
|
||||
kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
||||
return boto3.client("s3", **kwargs)
|
||||
|
||||
|
||||
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Upload one CA snapshot and return its S3 key."""
|
||||
if not settings.ocsp_s3_bucket:
|
||||
raise RuntimeError("KCA_OCSP_S3_BUCKET is not configured")
|
||||
if snapshot is None:
|
||||
snapshot = await snapshot_for_ca(ca)
|
||||
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
||||
key = object_key(ca.fingerprint_sha256)
|
||||
|
||||
# boto3 is synchronous and credential resolution can block; do both the
|
||||
# client creation and the PUT outside the event loop.
|
||||
def upload() -> None:
|
||||
client = _s3_client()
|
||||
client.put_object(
|
||||
Bucket=settings.ocsp_s3_bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType="application/json",
|
||||
CacheControl="no-store",
|
||||
)
|
||||
|
||||
await asyncio.to_thread(upload)
|
||||
return key
|
||||
|
||||
|
||||
async def mark_ocsp_dirty(ca_id: UUID) -> None:
|
||||
"""Mark one issuing CA's status object as needing a republish."""
|
||||
if not settings.ocsp_s3_bucket:
|
||||
return
|
||||
row = await OcspSnapshot.get_or_none(ca_id=ca_id)
|
||||
if row is None:
|
||||
await OcspSnapshot.create(ca_id=ca_id, dirty=True)
|
||||
return
|
||||
if not row.dirty:
|
||||
row.dirty = True
|
||||
row.last_error = None # type: ignore[assignment]
|
||||
await row.save(update_fields=["dirty", "last_error", "updated_at"])
|
||||
|
||||
|
||||
async def mark_all_cas_dirty(vault: Vault) -> None:
|
||||
if not settings.ocsp_s3_bucket:
|
||||
return
|
||||
for ca in await Certificate.filter(vault_id=vault.id, is_ca=True):
|
||||
await mark_ocsp_dirty(ca.id)
|
||||
|
||||
|
||||
class OcspPublisherMixin(KayaMixin):
|
||||
"""Background publisher for dirty OCSP snapshots."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interval_seconds: int,
|
||||
ensure_context: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
self._interval = max(1, interval_seconds)
|
||||
self._ensure_context = ensure_context
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._bootstrapped = False
|
||||
|
||||
def apply(self, app: KayaApp) -> None:
|
||||
# This mixin only owns a background task; no request hooks.
|
||||
return None
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
if not settings.ocsp_s3_bucket:
|
||||
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
|
||||
return
|
||||
try:
|
||||
import boto3 # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"KCA_OCSP_S3_BUCKET is set but boto3 is not installed; "
|
||||
"install kca-app with the 's3' extra"
|
||||
) from exc
|
||||
self._task = loop.create_task(self._poll())
|
||||
log.info(
|
||||
"OCSP S3 publication enabled: bucket=%s prefix=%s interval=%ss",
|
||||
settings.ocsp_s3_bucket,
|
||||
settings.ocsp_s3_prefix or "(root)",
|
||||
self._interval,
|
||||
)
|
||||
|
||||
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
async def _poll(self) -> None:
|
||||
# Let the Tortoise mixin establish its database context before the
|
||||
# first query. Subsequent failures are retried on the next tick.
|
||||
await asyncio.sleep(self._interval)
|
||||
while True:
|
||||
try:
|
||||
if not self._bootstrapped:
|
||||
# Existing CAs may predate the publisher or have lost
|
||||
# their S3 objects; publish one snapshot for every CA on
|
||||
# the first successful tick.
|
||||
await self._ensure_context()
|
||||
for ca in await Certificate.filter(is_ca=True):
|
||||
await mark_ocsp_dirty(ca.id)
|
||||
self._bootstrapped = True
|
||||
await self.run_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("OCSP snapshot publisher iteration failed")
|
||||
await asyncio.sleep(self._interval)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
# The publisher runs outside an HTTP request task, so it must bind
|
||||
# Tortoise's per-loop context before querying models.
|
||||
await self._ensure_context()
|
||||
rows = await OcspSnapshot.filter(dirty=True).limit(25)
|
||||
for row in rows:
|
||||
ca = await Certificate.get_or_none(id=row.ca_id)
|
||||
if ca is None:
|
||||
row.dirty = False
|
||||
row.last_error = "CA certificate not found"
|
||||
await row.save(update_fields=["dirty", "last_error", "updated_at"])
|
||||
continue
|
||||
try:
|
||||
snapshot = await snapshot_for_ca(ca)
|
||||
key = await publish_snapshot(ca, snapshot)
|
||||
row.dirty = False
|
||||
row.published_at = datetime.now(timezone.utc)
|
||||
row.last_error = None # type: ignore[assignment]
|
||||
await row.save(
|
||||
update_fields=["dirty", "published_at", "last_error", "updated_at"]
|
||||
)
|
||||
log.info("published OCSP snapshot ca=%s key=%s", ca.id, key)
|
||||
except Exception as exc: # noqa: BLE001 - keep retrying the same row
|
||||
row.dirty = True
|
||||
row.last_error = str(exc)[:2000]
|
||||
await row.save(update_fields=["dirty", "last_error", "updated_at"])
|
||||
log.warning("OCSP snapshot publish failed ca=%s: %s", ca.id, exc)
|
||||
|
||||
|
||||
def publisher_mixin_from_settings(
|
||||
settings_obj: Settings,
|
||||
ensure_context: Callable[[], Awaitable[None]],
|
||||
) -> Optional[OcspPublisherMixin]:
|
||||
if not settings_obj.ocsp_s3_bucket:
|
||||
return None
|
||||
return OcspPublisherMixin(
|
||||
settings_obj.ocsp_publish_interval_seconds,
|
||||
ensure_context,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OcspPublisherMixin",
|
||||
"SCHEMA",
|
||||
"mark_all_cas_dirty",
|
||||
"mark_ocsp_dirty",
|
||||
"object_key",
|
||||
"publish_snapshot",
|
||||
"publisher_mixin_from_settings",
|
||||
"snapshot_for_ca",
|
||||
]
|
||||
@@ -19,7 +19,8 @@ from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob
|
||||
from ..auth import authenticated_user, require_auth
|
||||
from ..config import settings
|
||||
from ..http import JsonRequestError, read_json, send_error, send_json
|
||||
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, Request, Template
|
||||
from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, OcspSnapshot, Request, Template
|
||||
from ..ocsp import snapshot_for_ca
|
||||
from ..openapi import error_response, json_response, schema_ref
|
||||
from ..service import (
|
||||
ValidationError,
|
||||
@@ -136,6 +137,32 @@ def register(app: KayaApp, kca: "Kca") -> None:
|
||||
return
|
||||
await send_json(ctx, 200, result)
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ocsp-status")
|
||||
@operation(summary="OCSP status snapshot for a CA", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
async def ocsp_status(ctx: HttpContext, vault_id: str, cert_id: str) -> None:
|
||||
vault = await _vault(kca, ctx, vault_id)
|
||||
if vault is None:
|
||||
return
|
||||
cert = await _cert(vault, cert_id)
|
||||
if cert is None:
|
||||
await send_error(ctx, 404, "certificate not found")
|
||||
return
|
||||
if not cert.is_ca:
|
||||
await send_error(ctx, 400, "the certificate is not a CA")
|
||||
return
|
||||
row = await OcspSnapshot.get_or_none(ca_id=cert.id)
|
||||
snapshot = await snapshot_for_ca(cert)
|
||||
await send_json(ctx, 200, {
|
||||
"enabled": bool(settings.ocsp_s3_bucket),
|
||||
"bucket": settings.ocsp_s3_bucket,
|
||||
"prefix": settings.ocsp_s3_prefix,
|
||||
"dirty": row.dirty if row else bool(settings.ocsp_s3_bucket),
|
||||
"published_at": row.published_at.isoformat() if row and row.published_at else None,
|
||||
"last_error": row.last_error if row else None,
|
||||
"snapshot": snapshot,
|
||||
})
|
||||
|
||||
@app.GET("/api/vaults/${vault_id}/certificates/${cert_id}")
|
||||
@operation(summary="Certificate detail", tags=["certificates"])
|
||||
@require_auth(kca.oidc)
|
||||
|
||||
@@ -22,6 +22,7 @@ from kca.core.imports import ParsedItem
|
||||
from kca.core.names import DnEntry
|
||||
|
||||
from .config import settings
|
||||
from .ocsp import mark_all_cas_dirty, mark_ocsp_dirty
|
||||
from .models import (
|
||||
ApiToken,
|
||||
Authority,
|
||||
@@ -807,14 +808,19 @@ async def issue_certificate(
|
||||
request_obj.signed_at = datetime.now(timezone.utc)
|
||||
await request_obj.save(update_fields=["signed", "signed_at"])
|
||||
await relink_certificates(vault)
|
||||
if signer is not None:
|
||||
await mark_ocsp_dirty(signer.id)
|
||||
return row
|
||||
|
||||
|
||||
async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None:
|
||||
issuer_id = cert.issuer_id
|
||||
await Certificate.filter(issuer_id=cert.id).update(issuer_id=None)
|
||||
await Crl.filter(issuer_id=cert.id).update(issuer_id=None)
|
||||
await CertificateRevocation.filter(ca_id=cert.id).delete()
|
||||
await cert.delete()
|
||||
if issuer_id is not None:
|
||||
await mark_ocsp_dirty(issuer_id)
|
||||
|
||||
|
||||
async def preview_certificate(vault: Vault, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
@@ -946,6 +952,8 @@ async def renew_certificate(
|
||||
if payload.get("replace_old"):
|
||||
await delete_certificate_cascade(vault, cert)
|
||||
await relink_certificates(vault)
|
||||
if signer.id != cert.id:
|
||||
await mark_ocsp_dirty(signer.id)
|
||||
return row
|
||||
|
||||
|
||||
@@ -973,6 +981,7 @@ async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[s
|
||||
invalidity_date=parse_datetime(invalidity, "invalidity_date") if invalidity else None,
|
||||
reason=reason,
|
||||
)
|
||||
await mark_ocsp_dirty(ca.id)
|
||||
return row
|
||||
|
||||
|
||||
@@ -1369,6 +1378,7 @@ async def import_parsed_items(
|
||||
"crl_number": int(crl.crl_number or 0),
|
||||
},
|
||||
)
|
||||
await mark_all_cas_dirty(vault)
|
||||
return {"imported": imported, "skipped": skipped}
|
||||
|
||||
|
||||
|
||||
@@ -401,3 +401,103 @@ class BulkRevocationTests(unittest.TestCase):
|
||||
json={"certificate_ids": ["00000000-0000-0000-0000-000000000000"], "reason": "superseded"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class OcspSnapshotTests(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_snapshot_contains_status_and_reason(self) -> None:
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice"):
|
||||
vault_id = await _vault(client)
|
||||
ca_key = await _key(client, vault_id, "ocsp ca")
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "OCSP CA", ca=True)
|
||||
leaf_key = await _key(client, vault_id, "ocsp leaf")
|
||||
leaf = await _cert(client, vault_id, leaf_key["id"], "OCSP Leaf", signer_id=ca["id"])
|
||||
await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
||||
json={"reason": "keyCompromise"},
|
||||
)
|
||||
response = await client.get(
|
||||
f"/api/vaults/{vault_id}/certificates/{ca['id']}/ocsp-status"
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["enabled"] is False
|
||||
snapshot = payload["snapshot"]
|
||||
assert snapshot["schema"] == "kca-ocsp-status/v1"
|
||||
assert snapshot["issuer"]["sha256"] == ca["fingerprint_sha256"]
|
||||
entries = {entry["serial_hex"]: entry for entry in snapshot["entries"]}
|
||||
assert entries[leaf["serial_hex"]]["status"] == "revoked"
|
||||
assert entries[leaf["serial_hex"]]["reason"] == "keyCompromise"
|
||||
assert entries[leaf["serial_hex"]]["revoked_at"]
|
||||
|
||||
@async_test
|
||||
async def test_revocation_marks_snapshot_dirty(self) -> None:
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca import ocsp
|
||||
from kca.models import OcspSnapshot
|
||||
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice"):
|
||||
vault_id = await _vault(client)
|
||||
ca_key = await _key(client, vault_id, "dirty ca")
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "Dirty CA", ca=True)
|
||||
leaf_key = await _key(client, vault_id, "dirty leaf")
|
||||
leaf = await _cert(client, vault_id, leaf_key["id"], "Dirty Leaf", signer_id=ca["id"])
|
||||
configured = replace(ocsp.settings, ocsp_s3_bucket="test-bucket")
|
||||
with patch.object(ocsp, "settings", configured):
|
||||
response = await client.post(
|
||||
f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke",
|
||||
json={"reason": "superseded"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||
assert row.dirty is True
|
||||
|
||||
@async_test
|
||||
async def test_publisher_marks_dirty_and_uploads(self) -> None:
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
|
||||
from kca import ocsp
|
||||
from kca.models import OcspSnapshot
|
||||
|
||||
class FakeS3:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
fake = FakeS3()
|
||||
async with api_client() as client:
|
||||
with oidc_user("alice"):
|
||||
vault_id = await _vault(client)
|
||||
ca_key = await _key(client, vault_id, "ocsp publish ca")
|
||||
ca = await _cert(client, vault_id, ca_key["id"], "OCSP Publish CA", ca=True)
|
||||
configured = replace(
|
||||
ocsp.settings,
|
||||
ocsp_s3_bucket="test-bucket",
|
||||
ocsp_s3_prefix="ocsp",
|
||||
)
|
||||
with patch.object(ocsp, "settings", configured), patch.object(
|
||||
ocsp, "_s3_client", lambda: fake
|
||||
):
|
||||
await ocsp.mark_ocsp_dirty(ca["id"])
|
||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||
assert row.dirty is True
|
||||
async def ensure_context():
|
||||
return None
|
||||
|
||||
mixin = ocsp.OcspPublisherMixin(1, ensure_context)
|
||||
await mixin.run_once()
|
||||
assert len(fake.calls) == 1
|
||||
call = fake.calls[0]
|
||||
assert call["Bucket"] == "test-bucket"
|
||||
assert call["Key"] == f"ocsp/{ca['fingerprint_sha256']}.json"
|
||||
assert b"kca-ocsp-status/v1" in call["Body"]
|
||||
row = await OcspSnapshot.get(ca_id=ca["id"])
|
||||
assert row.dirty is False
|
||||
assert row.published_at is not None
|
||||
|
||||
Generated
+75
-1
@@ -309,6 +309,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.118"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8b/5694c8433b5202f34a82def4edee274f7df1eba2919c66d9d389d2202ec8/boto3-1.43.118.tar.gz", hash = "sha256:c0620f799e0f0c222ba42c337b65cdbcd3da38a3121187f634d8926cbbb28a9c", size = 112649, upload-time = "2026-10-22T19:22:09.629Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a3/65bb3d64928f3c2da17640b92044084150317f02adf22442cb0c52af2a8b/boto3-1.43.118-py3-none-any.whl", hash = "sha256:ab8f3d2c622ef039338b4f228d97ea55f1abd573d84bb9b9a5ee8cb6780c0ccd", size = 140045, upload-time = "2026-10-22T19:22:08.353Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.118"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/b8/3e55154ddaf1a6d1d4ddda85c0e5983205a3b7657edacd8ebba8c6fd774c/botocore-1.43.118.tar.gz", hash = "sha256:7f3f9d88cc9e847587690f82a42e3c3c292c3026c92c1e60d68a9310018e003f", size = 16420126, upload-time = "2026-10-22T19:22:04.81Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/ce/61b39a8e7a69af5ea43c1c3e1b0065a4866b42ccddf107e16686a3143614/botocore-1.43.118-py3-none-any.whl", hash = "sha256:a45d7ae570674161e4a3542d8c68633bf7503f92d1de781ef7361b79488eda6f", size = 16122016, upload-time = "2026-10-22T19:22:00.414Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.10.19"
|
||||
@@ -876,6 +904,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaya-core"
|
||||
version = "0.0.8"
|
||||
@@ -1015,6 +1052,9 @@ otel = [
|
||||
{ name = "opentelemetry-instrumentation-redis" },
|
||||
{ name = "opentelemetry-instrumentation-system-metrics" },
|
||||
]
|
||||
s3 = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -1026,6 +1066,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aerich" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "boto3", marker = "extra == 's3'", specifier = ">=1.34" },
|
||||
{ name = "cryptography", specifier = ">=44" },
|
||||
{ name = "granian", specifier = ">=2.0" },
|
||||
{ name = "httpx" },
|
||||
@@ -1048,7 +1089,7 @@ requires-dist = [
|
||||
{ name = "redis" },
|
||||
{ name = "tortoise-orm", specifier = ">=0.25" },
|
||||
]
|
||||
provides-extras = ["otel"]
|
||||
provides-extras = ["s3", "otel"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -1635,6 +1676,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1726,6 +1779,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.19.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.5.0"
|
||||
|
||||
Reference in New Issue
Block a user