Files

355 lines
11 KiB
Python

"""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