🚧 Add CA and server cert management

This commit is contained in:
Jonas Bengtsson
2026-09-19 13:35:49 +02:00
parent 45de68f0b7
commit 111d6cc390
20 changed files with 1317 additions and 34 deletions
+231
View File
@@ -0,0 +1,231 @@
"""PKI management: internal CA + server certificates (spec sections 16-19).
All endpoints require the PKI_ADMIN role (or super_admin). Private keys
never appear in any response — the PEM download endpoints expose only
the public certificate material.
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import PlainTextResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app.crypto import encrypt_secret
from app.db import get_db
from app.deps import require_roles
from app.models import Certificate, ManagementRole
from app.pki import (
PkiError,
create_ca as pki_create_ca,
fingerprint as pki_fingerprint,
issue_server_certificate,
)
from app.schemas import CaCreate, CertificateOut, ServerCertCreate
router = APIRouter(prefix="/api/v1/pki", tags=["pki"])
PkiAdmin = Annotated[
object, Depends(require_roles(ManagementRole.PKI_ADMIN))
]
DbDep = Annotated[Session, Depends(get_db)]
def _get_certificate(db: Session, cert_id: uuid.UUID) -> Certificate:
cert = db.get(Certificate, cert_id)
if cert is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "certificate not found")
return cert
@router.get("/ca", response_model=list[CertificateOut])
def list_cas(db: DbDep, _user: PkiAdmin):
"""All certificate authorities in the store."""
return db.scalars(
select(Certificate).where(Certificate.is_ca.is_(True))
).all()
@router.post("/ca", response_model=CertificateOut, status_code=201)
def create_ca(body: CaCreate, request: Request, db: DbDep, _user: PkiAdmin):
"""Create a self-signed internal CA (spec section 17)."""
try:
issued = pki_create_ca(
common_name=body.common_name,
validity_days=body.validity_days,
key_algorithm=body.key_algorithm,
organization=body.organization,
organizational_unit=body.organizational_unit,
country=body.country,
)
except PkiError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
ca = Certificate(
common_name=body.common_name,
certificate_pem=issued.certificate_pem,
private_key_pem=encrypt_secret(issued.private_key_pem),
expiry_date=issued.not_valid_after,
is_ca=True,
key_algorithm=body.key_algorithm,
serial_number=str(issued.serial_number),
dns_sans=[],
ip_sans=[],
fingerprint=pki_fingerprint(issued.certificate_pem),
status="issued",
)
db.add(ca)
db.flush()
audit.record(
db,
"CA_CREATED",
request=request,
object_type="certificate_authority",
object_id=str(ca.id),
new_value={"common_name": body.common_name},
)
return ca
@router.get("/certificates", response_model=list[CertificateOut])
def list_certificates(db: DbDep, _user: PkiAdmin):
"""Full certificate inventory (spec section 21), newest first."""
return db.scalars(
select(Certificate).order_by(Certificate.created_at.desc())
).all()
@router.post("/certificates", response_model=CertificateOut, status_code=201)
def issue_certificate(
body: ServerCertCreate, request: Request, db: DbDep, _user: PkiAdmin
):
"""Issue a server certificate signed by the given CA (spec section 19)."""
from app.crypto import decrypt_secret
ca = _get_certificate(db, body.ca_id)
if not ca.is_ca:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "the referenced certificate is not a CA"
)
if ca.private_key_pem is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the CA private key is not held by the platform (offline CA); "
"it cannot issue certificates",
)
try:
ca_key_pem = decrypt_secret(ca.private_key_pem)
except ValueError as exc:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
f"could not decrypt the CA key: {exc}",
) from exc
try:
issued = issue_server_certificate(
ca_certificate_pem=ca.certificate_pem,
ca_private_key_pem=ca_key_pem,
common_name=body.common_name,
validity_days=body.validity_days,
dns_sans=body.dns_sans,
ip_sans=body.ip_sans,
organization=body.organization,
organizational_unit=body.organizational_unit,
country=body.country,
)
except PkiError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
cert = Certificate(
common_name=body.common_name,
certificate_pem=issued.certificate_pem,
private_key_pem=encrypt_secret(issued.private_key_pem),
expiry_date=issued.not_valid_after,
is_ca=False,
parent_id=ca.id,
key_algorithm="ecdsa-p256",
serial_number=str(issued.serial_number),
dns_sans=body.dns_sans,
ip_sans=body.ip_sans,
fingerprint=pki_fingerprint(issued.certificate_pem),
status="issued",
)
db.add(cert)
db.flush()
audit.record(
db,
"CERTIFICATE_ISSUED",
request=request,
object_type="certificate",
object_id=str(cert.id),
new_value={
"common_name": body.common_name,
"ca": ca.common_name,
"dns_sans": body.dns_sans,
},
)
return cert
@router.get(
"/certificates/{cert_id}/download",
response_class=PlainTextResponse,
)
def download_certificate(cert_id: uuid.UUID, db: DbDep, _user: PkiAdmin):
"""Download the certificate (public part only) as PEM."""
cert = _get_certificate(db, cert_id)
return PlainTextResponse(
cert.certificate_pem,
media_type="application/x-pem-file",
headers={
"Content-Disposition": f'attachment; filename="{cert.common_name}.crt"'
},
)
@router.delete(
"/certificates/{cert_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_certificate(
cert_id: uuid.UUID, request: Request, db: DbDep, _user: PkiAdmin
):
"""Remove a certificate from the store.
CAs with still-issued children are refused, and a certificate that a
listener is bound to cannot be deleted (the generated config would
reference a missing file).
"""
cert = _get_certificate(db, cert_id)
if cert.is_ca:
children = db.scalar(
select(Certificate.id).where(Certificate.parent_id == cert.id)
)
if children is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the CA still has issued certificates; delete them first",
)
from app.models import ListenerConfig
bound = db.scalar(
select(ListenerConfig.id).where(ListenerConfig.cert_id == cert.id)
)
if bound is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the certificate is bound to a listener; unbind it first",
)
db.delete(cert)
audit.record(
db,
"CERTIFICATE_DELETED",
request=request,
object_type="certificate",
object_id=str(cert_id),
)
return None