mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +00:00
a6166d11b9
New /api/ssl/csrs endpoint group: generate a private key + CSR server-side
(RSA 2048/4096, ECDSA P-256/P-384; full subject + DNS SANs with wildcard
support), list/detail/delete CSRs, and import the CA-signed certificate.
- New ssl_csrs table (SCHEMA_VERSION 9 -> 10, additive + idempotent); the
migration re-raises on failure so a failed run is retried instead of being
stamped as applied.
- Import verifies the certificate against the stored key as a hard gate
(match=None is treated as an integrity error, not a lenient pass), rejects
malformed and expired certificates with 400, warns on SAN drift, and
creates a normal ssl_certificates row (source=csr, cluster_id=NULL,
last_config_status=PENDING) so it flows through the standard
Apply Management -> agent pull pipeline.
- Concurrency: FOR UPDATE row lock serialises double-import and
delete-during-import; a partial unique index reserves pending CSR names;
soft-deleted same-name certs are reactivated preserving the row id.
- Security: no CSR endpoint ever returns the private key (explicit column
lists, enforced by a static test); the key copy on the CSR row is NULLed
after import; ssl.create/read/delete permissions enforced on every
endpoint incl. reads; per-user rate limit on key generation, which runs
in a worker thread; csr_id and cluster_ids are int32-guarded.
- ssl_service: extract _prepare_cert_fields from create_cert_row (behaviour
unchanged, extraction tests untouched) and add stage_ssl_config_versions
reusing the exact ssl-{id}-create-{ts} version-name scheme.
- Tests: crypto round-trip for all four algorithms, model validation,
import-flow unit tests, endpoint auth/permission pinning, migration and
key-non-exposure static assertions.
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""
|
|
CSR (Certificate Signing Request) endpoints (v1.9.0).
|
|
|
|
Generate a private key + CSR in-app, download the CSR PEM, have it signed by
|
|
an external CA, then import the signed certificate — which creates a normal
|
|
ssl_certificates row that flows through the existing pipeline
|
|
(config version → Apply Management → agent pull).
|
|
|
|
Security posture:
|
|
- All endpoints enforce ssl.* permissions explicitly (including the read
|
|
endpoints — deliberately stricter than the legacy cert detail route).
|
|
- The private key is NEVER returned by any endpoint here; after import it is
|
|
reachable only via the existing certificate detail route.
|
|
- Key generation is offloaded to a thread (RSA-4096 takes seconds; the
|
|
backend runs a single-worker event loop by default) and rate-limited
|
|
per user via the user_activity_logs COUNT pattern (acme_diagnostics
|
|
precedent — slowapi is not registered on the app).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Header
|
|
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from auth_middleware import get_current_user_from_token, check_user_permission
|
|
from models.csr import SSLCSRCreate, SSLCSRImport
|
|
from services import csr_service, ssl_service
|
|
from routers.ssl import _assert_safe_cert_name, validate_user_cluster_access
|
|
from utils.activity_log import log_user_activity
|
|
|
|
router = APIRouter(prefix="/api/ssl/csrs", tags=["SSL CSRs"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_RATE_LIMIT_CREATE_PER_MIN = 10
|
|
|
|
# Columns exposed to the API — private_key_pem is deliberately absent so a
|
|
# future `SELECT *` refactor cannot silently start leaking it.
|
|
_CSR_LIST_COLUMNS = """
|
|
c.id, c.name, c.common_name, c.subject, c.sans, c.key_algorithm,
|
|
c.status, c.ssl_certificate_id, c.completed_at, c.created_at, c.updated_at,
|
|
s.name AS certificate_name, u.username AS created_by_username
|
|
"""
|
|
|
|
_INT32_MAX = 2_147_483_647
|
|
|
|
|
|
def _client_ip(request: Optional[Request]) -> Optional[str]:
|
|
try:
|
|
return str(request.client.host) if request and request.client else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _user_agent(request: Optional[Request]) -> Optional[str]:
|
|
try:
|
|
return request.headers.get("user-agent") if request else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def _require(authorization: Optional[str], action: str):
|
|
"""Authenticate + enforce ssl.<action>; returns current_user or raises 401/403."""
|
|
current_user = await get_current_user_from_token(authorization)
|
|
ok = await check_user_permission(current_user["id"], "ssl", action, current_user=current_user)
|
|
if not ok:
|
|
raise HTTPException(status_code=403, detail=f"Insufficient permissions: ssl.{action} required")
|
|
return current_user
|
|
|
|
|
|
def _assert_int32_id(csr_id: int) -> None:
|
|
"""ssl_csrs.id is int4 — an out-of-range path param would surface as an
|
|
asyncpg DataError 500 (Bulgu #96 precedent); return a clean 404 instead."""
|
|
if csr_id < 1 or csr_id > _INT32_MAX:
|
|
raise HTTPException(status_code=404, detail="CSR not found")
|
|
|
|
|
|
def _assert_valid_cluster_id(cluster_id: int) -> None:
|
|
"""Same int4 guard for body-supplied cluster ids: haproxy_clusters.id is
|
|
SERIAL/int4, so an out-of-range value would raise asyncpg DataError inside
|
|
validate_user_cluster_access and surface as a 500 with the raw driver
|
|
error. Fail with the same clean 404 the cluster lookup itself produces."""
|
|
if not isinstance(cluster_id, int) or cluster_id < 1 or cluster_id > _INT32_MAX:
|
|
raise HTTPException(status_code=404, detail="Cluster not found")
|
|
|
|
|
|
async def _enforce_create_rate_limit(conn, user_id: int) -> None:
|
|
"""Per-user per-minute limit on key generation, counted against the
|
|
csr_create audit-log action (acme_diagnostics _enforce_rate_limit pattern,
|
|
backed by the (user_id, action, created_at DESC) composite index)."""
|
|
cnt = await conn.fetchval(
|
|
"""
|
|
SELECT COUNT(*)
|
|
FROM user_activity_logs
|
|
WHERE user_id = $1
|
|
AND action = 'csr_create'
|
|
AND created_at >= NOW() - INTERVAL '60 seconds'
|
|
""",
|
|
user_id,
|
|
)
|
|
if cnt is not None and cnt >= _RATE_LIMIT_CREATE_PER_MIN:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=(
|
|
f"Rate limit exceeded: at most {_RATE_LIMIT_CREATE_PER_MIN} "
|
|
"CSRs may be created per minute"
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("")
|
|
async def create_csr(payload: SSLCSRCreate, request: Request, authorization: Optional[str] = Header(None)):
|
|
"""Generate a private key + CSR. Returns the CSR PEM immediately (so the
|
|
UI can show copy/download in one round trip) — never the private key."""
|
|
current_user = await _require(authorization, "create")
|
|
conn = None
|
|
try:
|
|
# Belt and braces on top of the model validator — same duplication
|
|
# convention as the certificate create route.
|
|
_assert_safe_cert_name(payload.name)
|
|
|
|
conn = await get_database_connection()
|
|
await _enforce_create_rate_limit(conn, current_user["id"])
|
|
|
|
# Fail fast on a taken name BEFORE burning CPU on key generation;
|
|
# insert_csr_row re-checks and the partial unique index closes the race.
|
|
await csr_service.assert_csr_name_available(conn, payload.name)
|
|
|
|
bundle = await asyncio.to_thread(csr_service.generate_csr_bundle, payload)
|
|
csr_id = await csr_service.insert_csr_row(conn, payload, bundle, current_user["id"])
|
|
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
SELECT {_CSR_LIST_COLUMNS}, c.csr_pem
|
|
FROM ssl_csrs c
|
|
LEFT JOIN ssl_certificates s ON c.ssl_certificate_id = s.id
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
WHERE c.id = $1
|
|
""",
|
|
csr_id,
|
|
)
|
|
|
|
await log_user_activity(
|
|
user_id=current_user["id"],
|
|
action='csr_create',
|
|
resource_type='ssl_csr',
|
|
resource_id=str(csr_id),
|
|
details={
|
|
'csr_name': payload.name,
|
|
'common_name': payload.common_name,
|
|
'sans': bundle['sans'],
|
|
'key_algorithm': payload.key_algorithm,
|
|
},
|
|
ip_address=_client_ip(request),
|
|
user_agent=_user_agent(request),
|
|
)
|
|
|
|
return {
|
|
"message": f"CSR '{payload.name}' created successfully",
|
|
"csr": csr_service.csr_row_to_dict(row, include_pem=True),
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error creating CSR: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
if conn:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.get("")
|
|
async def list_csrs(authorization: Optional[str] = Header(None)):
|
|
"""List CSRs (no PEM payloads — fetch the detail route for the CSR PEM).
|
|
Cluster-agnostic: a CSR binds to clusters only at import time."""
|
|
await _require(authorization, "read")
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT {_CSR_LIST_COLUMNS}
|
|
FROM ssl_csrs c
|
|
LEFT JOIN ssl_certificates s ON c.ssl_certificate_id = s.id
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
ORDER BY c.created_at DESC
|
|
"""
|
|
)
|
|
return [csr_service.csr_row_to_dict(r) for r in rows]
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error listing CSRs: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
if conn:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.get("/{csr_id}")
|
|
async def get_csr(csr_id: int, authorization: Optional[str] = Header(None)):
|
|
"""CSR detail including the CSR PEM. The private key is never included."""
|
|
await _require(authorization, "read")
|
|
_assert_int32_id(csr_id)
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
row = await conn.fetchrow(
|
|
f"""
|
|
SELECT {_CSR_LIST_COLUMNS}, c.csr_pem
|
|
FROM ssl_csrs c
|
|
LEFT JOIN ssl_certificates s ON c.ssl_certificate_id = s.id
|
|
LEFT JOIN users u ON c.created_by = u.id
|
|
WHERE c.id = $1
|
|
""",
|
|
csr_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="CSR not found")
|
|
return csr_service.csr_row_to_dict(row, include_pem=True)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching CSR {csr_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
if conn:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.post("/{csr_id}/import")
|
|
async def import_csr_certificate(
|
|
csr_id: int,
|
|
payload: SSLCSRImport,
|
|
request: Request,
|
|
authorization: Optional[str] = Header(None),
|
|
):
|
|
"""Import the CA-signed certificate for a pending CSR. Creates an
|
|
ssl_certificates row (source='csr', PENDING) and stages one config
|
|
version per affected cluster — the operator applies manually."""
|
|
current_user = await _require(authorization, "create")
|
|
_assert_int32_id(csr_id)
|
|
conn = None
|
|
try:
|
|
if payload.name:
|
|
_assert_safe_cert_name(payload.name)
|
|
|
|
conn = await get_database_connection()
|
|
|
|
if not payload.is_global:
|
|
for cluster_id in payload.cluster_ids or []:
|
|
_assert_valid_cluster_id(cluster_id)
|
|
await validate_user_cluster_access(current_user["id"], cluster_id, conn)
|
|
|
|
result = await csr_service.import_signed_certificate(
|
|
conn, csr_id, payload, current_user["id"]
|
|
)
|
|
cert_id = result["certificate_id"]
|
|
|
|
if payload.is_global:
|
|
cluster_rows = await conn.fetch(
|
|
"SELECT id FROM haproxy_clusters WHERE is_active = TRUE"
|
|
)
|
|
affected_clusters = [r['id'] for r in cluster_rows]
|
|
else:
|
|
affected_clusters = payload.cluster_ids or []
|
|
|
|
# Post-commit staging — a config-generation failure never rolls back
|
|
# the certificate (same semantics as the manual create flow).
|
|
sync_results = await ssl_service.stage_ssl_config_versions(
|
|
conn, cert_id, affected_clusters, action='create',
|
|
created_by=current_user["id"],
|
|
)
|
|
|
|
await log_user_activity(
|
|
user_id=current_user["id"],
|
|
action='create',
|
|
resource_type='ssl_certificate',
|
|
resource_id=str(cert_id),
|
|
details={
|
|
'certificate_name': result['certificate_name'],
|
|
'domain': result.get('primary_domain', 'unknown'),
|
|
'via': 'csr',
|
|
'csr_id': csr_id,
|
|
'usage_type': payload.usage_type,
|
|
'is_global': payload.is_global,
|
|
'cluster_ids': payload.cluster_ids,
|
|
'warnings': result['warnings'],
|
|
},
|
|
ip_address=_client_ip(request),
|
|
user_agent=_user_agent(request),
|
|
)
|
|
await log_user_activity(
|
|
user_id=current_user["id"],
|
|
action='csr_import',
|
|
resource_type='ssl_csr',
|
|
resource_id=str(csr_id),
|
|
details={
|
|
'certificate_id': cert_id,
|
|
'certificate_name': result['certificate_name'],
|
|
},
|
|
ip_address=_client_ip(request),
|
|
user_agent=_user_agent(request),
|
|
)
|
|
|
|
return {
|
|
"message": (
|
|
f"Certificate '{result['certificate_name']}' imported "
|
|
"successfully. Go to Apply Management to deploy."
|
|
),
|
|
"certificate_id": cert_id,
|
|
"warnings": result["warnings"],
|
|
"sync_results": sync_results,
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error importing signed certificate for CSR {csr_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
if conn:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.delete("/{csr_id}")
|
|
async def delete_csr(csr_id: int, request: Request, authorization: Optional[str] = Header(None)):
|
|
"""Hard delete. For a pending CSR this permanently destroys the private
|
|
key (any certificate later signed from that CSR becomes unusable); for a
|
|
completed CSR it only removes history — the imported certificate is not
|
|
affected (the FK points csr → cert)."""
|
|
current_user = await _require(authorization, "delete")
|
|
_assert_int32_id(csr_id)
|
|
conn = None
|
|
try:
|
|
conn = await get_database_connection()
|
|
async with conn.transaction():
|
|
# FOR UPDATE serialises against an in-flight import of the same CSR.
|
|
row = await conn.fetchrow(
|
|
"SELECT id, name, status FROM ssl_csrs WHERE id = $1 FOR UPDATE",
|
|
csr_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="CSR not found")
|
|
await conn.execute("DELETE FROM ssl_csrs WHERE id = $1", csr_id)
|
|
|
|
await log_user_activity(
|
|
user_id=current_user["id"],
|
|
action='delete',
|
|
resource_type='ssl_csr',
|
|
resource_id=str(csr_id),
|
|
details={'csr_name': row['name'], 'status': row['status']},
|
|
ip_address=_client_ip(request),
|
|
user_agent=_user_agent(request),
|
|
)
|
|
|
|
if row['status'] == 'pending':
|
|
message = (
|
|
f"CSR '{row['name']}' deleted — its private key has been "
|
|
"permanently destroyed."
|
|
)
|
|
else:
|
|
message = (
|
|
f"CSR '{row['name']}' deleted (history only) — the imported "
|
|
"certificate is not affected."
|
|
)
|
|
return {"message": message}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error deleting CSR {csr_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
if conn:
|
|
await close_database_connection(conn)
|