🚧 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
+4
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
session_secret: str = ""
session_ttl_seconds: int = 60 * 60 * 8
# Private-key encryption at rest (spec section 43) — AES-256-GCM
# master key. Never stored in PostgreSQL.
secret_encryption_key: str = ""
# Mosquitto management channel
mosquitto_host: str = "mosquitto"
mosquitto_mgmt_port: int = 1883
+37 -15
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import BrokerSettings, ListenerConfig
from app.models import BrokerSettings, Certificate, ListenerConfig
_BOOL = {True: "true", False: "false"}
@@ -34,7 +34,9 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]:
return lines
def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
def _listener_block(
listener: ListenerConfig, db: Session, warnings: list[str]
) -> list[str]:
lines: list[str] = []
if not listener.enabled:
return lines
@@ -51,18 +53,38 @@ def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
if listener.max_connections != -1:
lines.append(f"max_connections {listener.max_connections}")
if listener.tls_enabled:
# TLS blocks only become valid once PKI ships (Release 2); we
# generate them now so the model is forward-compatible, but warn.
warnings.append(
f"listener '{listener.name}': TLS requested but PKI is not "
"available in Release 1; certfile/keyfile not set"
)
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
if listener.require_certificate:
lines.append("require_certificate true")
if listener.use_identity_as_username:
lines.append("use_identity_as_username true")
# Real PKI material (spec section 30): when a certificate is bound,
# the deploy pipeline publishes cert/key/CA files under
# /mosquitto/certs/<id>.crt|key BEFORE the config referencing
# them is activated, so validation sees real files.
cert = db.get(Certificate, listener.cert_id) if listener.cert_id else None
ca = db.get(Certificate, cert.parent_id) if cert and cert.parent_id else None
if cert is not None:
cert_dir = "/mosquitto/certs"
lines.append(f"certfile {cert_dir}/{cert.id}.crt")
lines.append(f"keyfile {cert_dir}/{cert.id}.key")
if ca is not None:
lines.append(f"cafile {cert_dir}/{ca.id}.crt")
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
if listener.require_certificate:
lines.append("require_certificate true")
if listener.use_identity_as_username:
lines.append("use_identity_as_username true")
else:
# TLS requested but no certificate bound: forward-compatible
# block without certfile/keyfile, with an actionable warning.
warnings.append(
f"listener '{listener.name}': TLS requested but no certificate "
"is bound; certfile/keyfile not set. Issue a server certificate "
"and bind it to this listener."
)
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
if listener.require_certificate:
lines.append("require_certificate true")
if listener.use_identity_as_username:
lines.append("use_identity_as_username true")
return lines
@@ -101,7 +123,7 @@ def generate_config(db: Session) -> GenerationResult:
if enabled:
lines.append("# --- Listeners ---")
for listener in enabled:
block = _listener_block(listener, warnings)
block = _listener_block(listener, db, warnings)
if block:
lines.append(f"# listener: {listener.name}")
lines.extend(block)
+59
View File
@@ -0,0 +1,59 @@
"""Key encryption at rest (spec sections 43-44).
Private keys that the platform must keep (CA and server keys) are stored
in PostgreSQL ONLY in encrypted form, using AES-256-GCM authenticated
encryption. The master key comes from the SECRET_ENCRYPTION_KEY
environment variable (Docker secret in production) and never touches
the database.
Wire format: "v1:<nonce-hex>:<ciphertext-hex>" — the version prefix
keeps the format evolvable without breaking existing rows.
"""
from __future__ import annotations
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import get_settings
_FORMAT = "v1"
def _master_key() -> bytes:
"""Derive the 32-byte AES key from the configured secret.
The secret is hashed to exactly 32 bytes (AES-256) regardless of the
length the operator chose for SECRET_ENCRYPTION_KEY.
"""
import hashlib
secret = get_settings().secret_encryption_key.encode("utf-8")
return hashlib.sha256(secret).digest()
def encrypt_secret(plaintext: str) -> str:
"""Encrypt a PEM string into the storage format."""
nonce = os.urandom(12) # 96-bit nonce, GCM standard
aesgcm = AESGCM(_master_key())
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return f"{_FORMAT}:{nonce.hex()}:{ciphertext.hex()}"
def decrypt_secret(blob: str) -> str:
"""Decrypt a stored secret back to plaintext PEM."""
try:
version, nonce_hex, ciphertext_hex = blob.split(":", 2)
except ValueError as exc:
raise ValueError("corrupt secret blob: bad format") from exc
if version != _FORMAT:
raise ValueError(f"unsupported secret blob version: {version}")
aesgcm = AESGCM(_master_key())
try:
plaintext = aesgcm.decrypt(
bytes.fromhex(nonce_hex), bytes.fromhex(ciphertext_hex), None
)
except Exception as exc:
raise ValueError("secret decryption failed (wrong master key?)") from exc
return plaintext.decode("utf-8")
+3 -2
View File
@@ -125,9 +125,10 @@ def health_ready():
# Routers
from app.routers import auth, broker, mqtt, users # noqa: E402
from app.routers import auth, broker, mqtt, pki, users # noqa: E402
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(mqtt.router)
app.include_router(broker.router)
app.include_router(broker.router)
app.include_router(pki.router)
+22 -1
View File
@@ -150,15 +150,36 @@ class ConfigRevision(Base):
class Certificate(Base):
"""A CA or leaf certificate in the platform PKI store (spec 16-21).
Hierarchy: a CA row has is_ca=True and parent_id=None (internal
self-signed root/intermediate, Release 2 MVP keeps exactly one level).
A leaf row points at the CA that signed it. Private keys are stored
ENCRYPTED (AES-256-GCM, see app/crypto.py) — never in plaintext.
"""
__tablename__ = "certificates"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
common_name: Mapped[str] = mapped_column(String(200))
certificate_pem: Mapped[str] = mapped_column(Text)
# Encrypted at rest (AES-256-GCM) once keys are managed (Release 2).
# Encrypted at rest (AES-256-GCM via app/crypto.py), spec section 43.
private_key_pem: Mapped[str | None] = mapped_column(Text)
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# --- Release 2 additions ---
is_ca: Mapped[bool] = mapped_column(Boolean, default=False)
# CA that signed this certificate (None for self-signed CAs).
parent_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("certificates.id", ondelete="SET NULL")
)
key_algorithm: Mapped[str] = mapped_column(String(20), default="ecdsa-p256")
serial_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
dns_sans: Mapped[list] = mapped_column(JSON, default=list)
ip_sans: Mapped[list] = mapped_column(JSON, default=list)
fingerprint: Mapped[str] = mapped_column(String(200), index=True)
# issued|expired|revoked — derived/stored at issue time and on expiry checks
status: Mapped[str] = mapped_column(String(20), default="issued")
# --------------------------------------------------------------------------
+223
View File
@@ -0,0 +1,223 @@
"""PKI operations: internal CA + server certificate issuance (spec 16-19).
All private-key and signing operations happen here, in the backend —
the frontend only triggers them (spec section 16). Keys are returned in
PEM form to the storage layer, which encrypts them at rest (spec 43).
Release 2 MVP scope:
* create an internal CA (RSA 3072/4096, ECDSA P-256/P-384)
* issue a server certificate from that CA (CN + DNS/IP SANs, serverAuth)
CSR signing, client certificates, revocation/CRL arrive later.
"""
from __future__ import annotations
import datetime
import uuid
from dataclasses import dataclass
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
from cryptography.x509.oid import NameOID
# Key algorithms supported for both CAs and leaf certificates (spec 17).
KEY_ALGORITHMS = ("rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384")
class PkiError(ValueError):
"""Invalid PKI request (bad algorithm, empty subject, ...)."""
def _generate_key(algorithm: str):
"""Generate a private key for the given algorithm identifier."""
if algorithm == "rsa3072":
return rsa.generate_private_key(public_exponent=65537, key_size=3072)
if algorithm == "rsa4096":
return rsa.generate_private_key(public_exponent=65537, key_size=4096)
if algorithm == "ecdsa-p256":
return ec.generate_private_key(ec.SECP256R1())
if algorithm == "ecdsa-p384":
return ec.generate_private_key(ec.SECP384R1())
raise PkiError(
f"unsupported key algorithm {algorithm!r}; expected one of {KEY_ALGORITHMS}"
)
def _name(common_name: str, organization: str | None, unit: str | None,
country: str | None) -> x509.Name:
attrs = [x509.NameAttribute(NameOID.COMMON_NAME, common_name)]
if organization:
attrs.append(x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization))
if unit:
attrs.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, unit))
if country:
attrs.append(x509.NameAttribute(NameOID.COUNTRY_NAME, country))
return x509.Name(attrs)
def _pem_key(key) -> str:
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
def _pem_cert(cert: x509.Certificate) -> str:
return cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
def _serial() -> int:
# Positive, 159-bit (20-byte) random serial per RFC 5280 conventions.
return int.from_bytes(uuid.uuid4().bytes[:20], "big") >> 1
def _not_after(now: datetime.datetime, validity_days: int) -> datetime.datetime:
if validity_days <= 0:
raise PkiError("validity must be a positive number of days")
return now + datetime.timedelta(days=validity_days)
@dataclass
class IssuedPair:
"""A certificate + its private key, both as PEM text."""
certificate_pem: str
private_key_pem: str
serial_number: int
not_valid_after: datetime.datetime
def create_ca(
*,
common_name: str,
validity_days: int,
key_algorithm: str,
organization: str | None = None,
organizational_unit: str | None = None,
country: str | None = None,
) -> IssuedPair:
"""Create a self-signed CA certificate (spec section 17)."""
if not common_name.strip():
raise PkiError("common name must not be empty")
now = datetime.datetime.now(datetime.UTC)
key = _generate_key(key_algorithm)
subject = issuer = _name(common_name, organization, organizational_unit, country)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(_serial())
.not_valid_before(now - datetime.timedelta(minutes=5)) # clock skew
.not_valid_after(_not_after(now, validity_days))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
), critical=True)
.sign(key, hashes.SHA256())
)
return IssuedPair(
certificate_pem=_pem_cert(cert),
private_key_pem=_pem_key(key),
serial_number=cert.serial_number,
not_valid_after=cert.not_valid_after_utc,
)
def issue_server_certificate(
*,
ca_certificate_pem: str,
ca_private_key_pem: str,
common_name: str,
validity_days: int,
dns_sans: list[str] | None = None,
ip_sans: list[str] | None = None,
organization: str | None = None,
organizational_unit: str | None = None,
country: str | None = None,
) -> IssuedPair:
"""Issue a server certificate signed by the given CA (spec section 19).
The certificate carries the serverAuth extended key usage and the
CN plus any DNS/IP SANs — exactly what a Mosquitto TLS listener needs.
"""
if not common_name.strip():
raise PkiError("common name must not be empty")
dns_sans = [s.strip() for s in (dns_sans or []) if s.strip()]
ip_sans = [s.strip() for s in (ip_sans or []) if s.strip()]
ca_cert = x509.load_pem_x509_certificate(ca_certificate_pem.encode("utf-8"))
ca_key = serialization.load_pem_private_key(
ca_private_key_pem.encode("utf-8"), password=None
)
# Type narrowing for the checker: certificate signing requires an
# RSA/EC/Ed25519/Ed448 key — DH/DSA keys load but cannot sign certs.
if not isinstance(
ca_key,
(
rsa.RSAPrivateKey,
ec.EllipticCurvePrivateKey,
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
),
):
raise PkiError("the CA private key type cannot sign certificates")
# The leaf key is always ECDSA P-256 for Release 2: fast to generate,
# modern, and universally supported by MQTT clients.
key = _generate_key("ecdsa-p256")
now = datetime.datetime.now(datetime.UTC)
subject = _name(common_name, organization, organizational_unit, country)
san_entries: list[x509.GeneralName] = [x509.DNSName(common_name)]
for name in dns_sans:
san_entries.append(x509.DNSName(name))
for ip in ip_sans:
try:
import ipaddress
san_entries.append(x509.IPAddress(ipaddress.ip_address(ip)))
except ValueError as exc:
raise PkiError(f"invalid IP SAN {ip!r}") from exc
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(key.public_key())
.serial_number(_serial())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(_not_after(now, validity_days))
.add_extension(
x509.SubjectAlternativeName(san_entries), critical=False
)
.add_extension(
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
return IssuedPair(
certificate_pem=_pem_cert(cert),
private_key_pem=_pem_key(key),
serial_number=cert.serial_number,
not_valid_after=cert.not_valid_after_utc,
)
def fingerprint(certificate_pem: str) -> str:
"""SHA-256 fingerprint of a certificate, colon-separated hex."""
cert = x509.load_pem_x509_certificate(certificate_pem.encode("utf-8"))
digest = cert.fingerprint(hashes.SHA256())
return ":".join(f"{b:02x}" for b in digest)
+49 -1
View File
@@ -8,6 +8,7 @@ an immutable revision (spec section 26).
from __future__ import annotations
import os
import uuid
from datetime import UTC, datetime
from typing import Annotated
@@ -24,6 +25,7 @@ from app.db import get_db
from app.deps import CurrentUser, require_roles
from app.models import (
BrokerSettings,
Certificate,
ConfigRevision,
ListenerConfig,
ManagementRole,
@@ -207,6 +209,51 @@ def upsert_setting(
# --- Generation, validation, deployment ----------------------------------------
# The sidecar and broker share the mosquitto_certs volume; the backend
# publishes the DECRYPTED material there because Mosquitto needs
# plaintext files (uid 1883, mode 600). The DB keeps the encrypted master.
CERTS_DIR = os.environ.get("MOSQUITTO_CERTS_DIR", "/mosquitto/certs")
def _publish_certificates(db: Session) -> None:
"""Write cert/key/CA files for every TLS listener's bound certificate.
Must run BEFORE the config referencing them is validated/activated,
otherwise `mosquitto --test-config` fails on missing files.
"""
listeners = db.scalars(
select(ListenerConfig).where(ListenerConfig.tls_enabled.is_(True))
).all()
seen: set[uuid.UUID] = set()
for listener in listeners:
if listener.cert_id is None or listener.cert_id in seen:
continue
seen.add(listener.cert_id)
cert = db.get(Certificate, listener.cert_id)
if cert is None or cert.private_key_pem is None:
continue # nothing to publish (warned about in configgen)
os.makedirs(CERTS_DIR, exist_ok=True)
cert_path = os.path.join(CERTS_DIR, f"{cert.id}.crt")
with open(cert_path, "w", encoding="utf-8") as f:
f.write(cert.certificate_pem)
os.chmod(cert_path, 0o644)
# Private key: decrypted for the broker (DB keeps the AES-GCM copy)
from app.crypto import decrypt_secret
key_path = os.path.join(CERTS_DIR, f"{cert.id}.key")
with open(key_path, "w", encoding="utf-8") as f:
f.write(decrypt_secret(cert.private_key_pem))
os.chmod(key_path, 0o600)
# Issuing CA certificate (for cafile)
if cert.parent_id is not None:
ca = db.get(Certificate, cert.parent_id)
if ca is not None:
ca_path = os.path.join(CERTS_DIR, f"{ca.id}.crt")
with open(ca_path, "w", encoding="utf-8") as f:
f.write(ca.certificate_pem)
os.chmod(ca_path, 0o644)
@router.get("/config/preview", response_model=ConfigPreviewOut)
def preview_config(
db: Annotated[Session, Depends(get_db)], _user: AnyReader
@@ -235,7 +282,8 @@ def deploy_config(
db: Annotated[Session, Depends(get_db)],
user: BrokerAdmin,
):
"""Generate -> validate -> activate + reload -> record revision."""
"""Generate -> publish certs -> validate -> activate + reload -> revision."""
_publish_certificates(db)
result = generate_config(db)
response = _call_control("/deploy", json_body={"content": result.config_text})
if response.get("status") != "deployed":
+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
+45 -1
View File
@@ -234,4 +234,48 @@ class RevisionOut(BaseModel):
class RevisionDetailOut(RevisionOut):
config_text: str
validation_result: dict[str, Any]
validation_result: dict[str, Any]
# --- PKI: certificate authorities + server certificates (spec 16-19) ---------
class CaCreate(BaseModel):
common_name: str = Field(min_length=1, max_length=200)
validity_days: int = Field(gt=0, le=36500)
key_algorithm: Literal["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"]
organization: str | None = None
organizational_unit: str | None = None
country: str | None = Field(default=None, max_length=2)
class ServerCertCreate(BaseModel):
"""Issue a server certificate signed by an existing CA (spec 19)."""
ca_id: uuid.UUID
common_name: str = Field(min_length=1, max_length=200)
validity_days: int = Field(gt=0, le=36500)
dns_sans: list[str] = Field(default_factory=list)
ip_sans: list[str] = Field(default_factory=list)
organization: str | None = None
organizational_unit: str | None = None
country: str | None = Field(default=None, max_length=2)
class CertificateOut(BaseModel):
"""Inventory view — NEVER includes the private key (spec section 21)."""
id: uuid.UUID
common_name: str
is_ca: bool
parent_id: uuid.UUID | None
key_algorithm: str
serial_number: str
dns_sans: list[str]
ip_sans: list[str]
fingerprint: str
status: str
expiry_date: datetime
created_at: datetime
model_config = {"from_attributes": True}