"""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 decrypt_secret, encrypt_secret from app.db import get_db from app.deps import require_roles from app.dynsec import DynsecError, dynsec_client from app.models import Certificate, ManagementRole, MqttClientMetadata from app.pki import ( PkiError, create_ca as pki_create_ca, fingerprint as pki_fingerprint, issue_client_certificate, issue_server_certificate, ) from app.schemas import ( CaCreate, CertificateBundle, CertificateOut, ClientCertCreate, 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, cert_type="ca", 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, cert_type="server", 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, "cert_type": "server", "dns_sans": body.dns_sans, }, ) return cert @router.post( "/certificates/client", response_model=CertificateBundle, status_code=201 ) def issue_client_cert( body: ClientCertCreate, request: Request, db: DbDep, _user: PkiAdmin ): """Issue a client certificate for an MQTT client (spec sections 20, 24). Validates that the dynsec client exists (the certificate CN becomes the MQTT identity via use_identity_as_username), issues the cert, binds it to the client metadata, and returns the one-time bundle — the private key is NEVER retrievable again afterwards. """ try: dynsec_client.get_client(body.username) except DynsecError as exc: raise HTTPException( status.HTTP_404_NOT_FOUND, f"MQTT client {body.username!r} does not exist at the broker; " "create it first — the certificate CN must match the username", ) from exc 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_client_certificate( ca_certificate_pem=ca.certificate_pem, ca_private_key_pem=ca_key_pem, common_name=body.username, validity_days=body.validity_days, ) except PkiError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc cert = Certificate( common_name=body.username, 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, cert_type="client", username=body.username, key_algorithm="ecdsa-p256", serial_number=str(issued.serial_number), dns_sans=[], ip_sans=[], fingerprint=pki_fingerprint(issued.certificate_pem), status="issued", ) db.add(cert) db.flush() # Bind the certificate to the client's PostgreSQL metadata (the # dynsec identity link, spec section 24). meta = db.get(MqttClientMetadata, body.username) if meta is None: meta = MqttClientMetadata(username=body.username, tags=[]) db.add(meta) meta.certificate_id = cert.id db.flush() audit.record( db, "CERTIFICATE_ISSUED", request=request, object_type="certificate", object_id=str(cert.id), new_value={ "common_name": body.username, "ca": ca.common_name, "cert_type": "client", "mqtt_client": body.username, }, ) return CertificateBundle( certificate_id=cert.id, username=body.username, certificate_pem=issued.certificate_pem, private_key_pem=issued.private_key_pem, ca_chain_pem=ca.certificate_pem, ) @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, MqttClientMetadata 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", ) if cert.cert_type == "client": # Auto-unlink the identity binding (FK is SET NULL at the DB # level too); the MQTT client itself is not affected. Explicit # flush: no query follows, so autoflush would never push this. linked = db.scalar( select(MqttClientMetadata).where( MqttClientMetadata.certificate_id == cert.id ) ) if linked is not None: linked.certificate_id = None db.flush() db.delete(cert) audit.record( db, "CERTIFICATE_DELETED", request=request, object_type="certificate", object_id=str(cert_id), ) return None