mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-18 00:26:51 +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.
470 lines
17 KiB
Python
470 lines
17 KiB
Python
"""
|
|
v1.9.0 CSR creation — unit tests for the signed-certificate import flow and
|
|
config-version staging (pattern: test_ssl_service_extraction.py, AsyncMock conn).
|
|
|
|
Pins the security-relevant invariants:
|
|
- key match is a HARD gate: match=False → 400 before any INSERT, and
|
|
match=None (unverifiable) → 500, never a lenient pass (we generated the
|
|
key ourselves — deliberate divergence from create_cert_row's fallback).
|
|
- the new cert row is cluster_id=NULL / last_config_status='PENDING' /
|
|
source='csr' (PENDING keeps it invisible to agents until Apply).
|
|
- completing the CSR NULLs the private key copy.
|
|
- staging reuses the exact `ssl-{id}-create-{ts}` version-name scheme.
|
|
"""
|
|
import json
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from models.csr import SSLCSRImport
|
|
from services.csr_service import (
|
|
assert_csr_name_available,
|
|
import_signed_certificate,
|
|
insert_csr_row,
|
|
)
|
|
from services.ssl_service import stage_ssl_config_versions
|
|
|
|
|
|
_VALID_PARSE = {
|
|
"primary_domain": "www.example.com",
|
|
"all_domains": ["www.example.com"],
|
|
"expiry_date": datetime(2099, 1, 1, tzinfo=timezone.utc),
|
|
"issuer": "CN=Test CA",
|
|
"fingerprint": "AA:BB:CC",
|
|
"status": "valid",
|
|
"days_until_expiry": 365,
|
|
}
|
|
|
|
_FAKE_CERT = "-----BEGIN CERTIFICATE-----\nX\n-----END CERTIFICATE-----"
|
|
_FAKE_KEY = "-----BEGIN PRIVATE KEY-----\nY\n-----END PRIVATE KEY-----"
|
|
|
|
|
|
def _csr_row(**overrides):
|
|
row = {
|
|
"id": 5,
|
|
"name": "csr-www",
|
|
"common_name": "www.example.com",
|
|
"subject": "{}",
|
|
"sans": json.dumps(["www.example.com"]),
|
|
"key_algorithm": "rsa-2048",
|
|
"csr_pem": "-----BEGIN CERTIFICATE REQUEST-----\nZ\n-----END CERTIFICATE REQUEST-----",
|
|
"private_key_pem": _FAKE_KEY,
|
|
"status": "pending",
|
|
"ssl_certificate_id": None,
|
|
}
|
|
row.update(overrides)
|
|
return row
|
|
|
|
|
|
def _mk_conn():
|
|
conn = AsyncMock()
|
|
# asyncpg's conn.transaction() is a SYNC call returning an async CM.
|
|
conn.transaction = MagicMock()
|
|
return conn
|
|
|
|
|
|
def _import_payload(**overrides):
|
|
base = dict(
|
|
certificate_content=_FAKE_CERT,
|
|
chain_content=None,
|
|
usage_type="frontend",
|
|
is_global=False,
|
|
cluster_ids=[1, 2],
|
|
name=None,
|
|
)
|
|
base.update(overrides)
|
|
return SSLCSRImport(**base)
|
|
|
|
|
|
@contextmanager
|
|
def _patched(match=None, parse=None):
|
|
"""Patch every parser touchpoint of the import path: the function-local
|
|
imports in csr_service (utils.ssl_parser.*) and the module-level imports
|
|
in ssl_service._prepare_cert_fields (services.ssl_service.*)."""
|
|
match_result = match if match is not None else {"match": True}
|
|
parse_result = dict(parse or _VALID_PARSE)
|
|
with patch("utils.ssl_parser.verify_certificate_key_match", return_value=match_result), \
|
|
patch("utils.ssl_parser.parse_ssl_certificate", return_value=dict(parse_result)), \
|
|
patch("services.ssl_service.parse_ssl_certificate", return_value=dict(parse_result)), \
|
|
patch("services.ssl_service.validate_private_key", return_value=True), \
|
|
patch("services.ssl_service.validate_certificate_chain", return_value=True):
|
|
yield
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# import_signed_certificate
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_happy_path_inserts_pending_csr_sourced_cert():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row(), None] # FOR UPDATE row, no name clash
|
|
conn.fetchval.return_value = 42 # INSERT ... RETURNING id
|
|
|
|
with _patched():
|
|
result = await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert result["certificate_id"] == 42
|
|
assert result["reactivated"] is False
|
|
|
|
# Concurrency invariants: everything runs inside a transaction and the
|
|
# CSR row is locked FOR UPDATE (serialises double-import and delete-races).
|
|
assert conn.transaction.call_count == 1
|
|
lock_sql = conn.fetchrow.call_args_list[0].args[0]
|
|
assert "FOR UPDATE" in lock_sql
|
|
|
|
insert_sql, *insert_args = conn.fetchval.call_args.args
|
|
assert "INSERT INTO ssl_certificates" in insert_sql
|
|
assert "NULL, 'PENDING'" in insert_sql, "cert must stay invisible to agents until Apply"
|
|
assert "'csr'" in insert_sql, "source column must record the CSR origin"
|
|
# The stored CSR key — not any request-supplied key — must be persisted.
|
|
assert _FAKE_KEY in insert_args
|
|
|
|
# One junction row per requested cluster.
|
|
junction_calls = [
|
|
c for c in conn.execute.call_args_list
|
|
if c.args and "ssl_certificate_clusters" in c.args[0] and "INSERT" in c.args[0]
|
|
]
|
|
assert len(junction_calls) == 2
|
|
assert {c.args[2] for c in junction_calls} == {1, 2}
|
|
|
|
# CSR completion must destroy the key copy.
|
|
completion_calls = [
|
|
c for c in conn.execute.call_args_list
|
|
if c.args and "UPDATE ssl_csrs" in c.args[0]
|
|
]
|
|
assert len(completion_calls) == 1
|
|
assert "private_key_pem = NULL" in completion_calls[0].args[0]
|
|
assert "status = 'completed'" in completion_calls[0].args[0]
|
|
assert completion_calls[0].args[1] == 5 # csr_id
|
|
assert completion_calls[0].args[2] == 42 # cert_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_global_creates_zero_junction_rows():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row(), None]
|
|
conn.fetchval.return_value = 42
|
|
|
|
with _patched():
|
|
await import_signed_certificate(
|
|
conn, 5, _import_payload(is_global=True, cluster_ids=None), user_id=7
|
|
)
|
|
|
|
junction_calls = [
|
|
c for c in conn.execute.call_args_list
|
|
if c.args and "ssl_certificate_clusters" in c.args[0] and "INSERT" in c.args[0]
|
|
]
|
|
assert junction_calls == [], "global cert = zero junction rows (existing convention)"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_key_mismatch_rejected_400_before_any_write():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row()]
|
|
|
|
with _patched(match={"match": False, "reason": "public key mismatch"}):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "does not match" in exc_info.value.detail
|
|
assert not conn.fetchval.await_count, "nothing must be inserted on mismatch"
|
|
assert not conn.execute.await_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_unverifiable_key_match_is_hard_error_not_lenient():
|
|
"""match=None means OUR stored key is unreadable — integrity failure,
|
|
never the lenient pass create_cert_row historically allows."""
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row()]
|
|
|
|
with _patched(match={"match": None, "reason": "key could not be parsed"}):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 500
|
|
assert not conn.fetchval.await_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_expired_certificate_rejected_400():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row()]
|
|
|
|
expired = dict(_VALID_PARSE)
|
|
expired["status"] = "expired"
|
|
expired["days_until_expiry"] = -10
|
|
with _patched(parse=expired):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "expired" in exc_info.value.detail.lower()
|
|
assert not conn.fetchval.await_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_malformed_certificate_rejected_400_not_500():
|
|
"""A cert with PEM markers but unparseable content (truncated CA response)
|
|
is OPERATOR INPUT — it must get the manual flow's 400, not the 500 that
|
|
the strict key-match branch reserves for a corrupt STORED key."""
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row()]
|
|
|
|
with _patched(parse={"error": "Could not parse certificate"}):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "Invalid SSL certificate" in exc_info.value.detail
|
|
assert not conn.fetchval.await_count
|
|
assert not conn.execute.await_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_completed_csr_conflicts_409():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row(status="completed", ssl_certificate_id=42)]
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 409
|
|
assert "already completed" in exc_info.value.detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_missing_csr_404():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [None]
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 999, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_active_name_collision_rejected_with_hint():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row(), {"id": 9, "is_active": True}]
|
|
|
|
with _patched():
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "already exists" in exc_info.value.detail
|
|
assert "name" in exc_info.value.detail # points at the override escape hatch
|
|
assert not conn.fetchval.await_count
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_name_override_is_used_for_the_cert_row():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row(), None]
|
|
conn.fetchval.return_value = 42
|
|
|
|
with _patched():
|
|
result = await import_signed_certificate(
|
|
conn, 5, _import_payload(name="renamed-cert"), user_id=7
|
|
)
|
|
|
|
assert result["certificate_name"] == "renamed-cert"
|
|
_, *insert_args = conn.fetchval.call_args.args
|
|
assert "renamed-cert" in insert_args
|
|
# And the collision check must have run against the override, not csr.name.
|
|
name_lookup = conn.fetchrow.call_args_list[1]
|
|
assert name_lookup.args[1] == "renamed-cert"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_reactivates_soft_deleted_name_and_warns():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [_csr_row(), {"id": 77, "is_active": False}]
|
|
|
|
with _patched():
|
|
result = await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert result["certificate_id"] == 77
|
|
assert result["reactivated"] is True
|
|
assert any("reactivated" in w for w in result["warnings"])
|
|
assert not conn.fetchval.await_count, "reactivation must UPDATE, not INSERT"
|
|
update_calls = [
|
|
c for c in conn.execute.call_args_list
|
|
if c.args and "UPDATE ssl_certificates" in c.args[0]
|
|
]
|
|
assert len(update_calls) == 1
|
|
update_sql = update_calls[0].args[0]
|
|
assert "source = 'csr'" in update_sql
|
|
# The reactivated row must come back to life invisible to agents until
|
|
# Apply, with the row itself active again.
|
|
assert "last_config_status = 'PENDING'" in update_sql
|
|
assert "is_active = TRUE" in update_sql
|
|
# Old cluster bindings must be wiped before re-binding to the new scope.
|
|
junction_deletes = [
|
|
c for c in conn.execute.call_args_list
|
|
if c.args and "DELETE FROM ssl_certificate_clusters" in c.args[0]
|
|
]
|
|
assert len(junction_deletes) == 1
|
|
assert junction_deletes[0].args[1] == 77
|
|
# …and the importer's requested clusters re-bound via the junction.
|
|
junction_inserts = [
|
|
c for c in conn.execute.call_args_list
|
|
if c.args and "INSERT INTO ssl_certificate_clusters" in c.args[0]
|
|
]
|
|
assert {c.args[2] for c in junction_inserts} == {1, 2}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_san_drift_warns_but_succeeds():
|
|
conn = _mk_conn()
|
|
conn.fetchrow.side_effect = [
|
|
_csr_row(sans=json.dumps(["www.example.com", "api.example.com"])),
|
|
None,
|
|
]
|
|
conn.fetchval.return_value = 42
|
|
|
|
drifted = dict(_VALID_PARSE)
|
|
drifted["all_domains"] = ["www.example.com", "cdn.example.com"]
|
|
with _patched(parse=drifted):
|
|
result = await import_signed_certificate(conn, 5, _import_payload(), user_id=7)
|
|
|
|
assert result["certificate_id"] == 42
|
|
assert any("added" in w and "cdn.example.com" in w for w in result["warnings"])
|
|
assert any("dropped" in w and "api.example.com" in w for w in result["warnings"])
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# insert_csr_row / assert_csr_name_available
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csr_name_taken_by_active_cert_rejected():
|
|
conn = _mk_conn()
|
|
conn.fetchval.side_effect = [11] # active cert with the name exists
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await assert_csr_name_available(conn, "taken")
|
|
assert exc_info.value.status_code == 400
|
|
assert "certificate" in exc_info.value.detail.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_csr_name_taken_by_pending_csr_rejected():
|
|
conn = _mk_conn()
|
|
conn.fetchval.side_effect = [None, 12] # no cert, but a pending CSR
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await assert_csr_name_available(conn, "taken")
|
|
assert exc_info.value.status_code == 400
|
|
assert "pending CSR" in exc_info.value.detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insert_csr_row_translates_unique_violation_to_400():
|
|
"""The uq_ssl_csrs_name_pending partial index closes the create/create
|
|
race — the loser must get a clean 400, not a 500."""
|
|
import asyncpg as _asyncpg
|
|
|
|
conn = _mk_conn()
|
|
# availability checks pass, INSERT hits the unique index
|
|
conn.fetchval.side_effect = [
|
|
None, None, _asyncpg.exceptions.UniqueViolationError("dup"),
|
|
]
|
|
payload = SimpleNamespace(
|
|
name="raced", common_name="www.example.com", key_algorithm="rsa-2048"
|
|
)
|
|
bundle = {"subject": {}, "sans": ["www.example.com"], "csr_pem": "PEM", "private_key_pem": "KEY"}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await insert_csr_row(conn, payload, bundle, user_id=1)
|
|
assert exc_info.value.status_code == 400
|
|
assert "concurrent" in exc_info.value.detail
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# router-level guards
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_cluster_id_int32_guard_rejects_out_of_range_with_404():
|
|
"""Body-supplied cluster ids must never reach asyncpg out of int4 range
|
|
(DataError → raw 500) — same Bulgu #96 hygiene as the csr_id path param."""
|
|
from routers.csr import _assert_valid_cluster_id
|
|
|
|
_assert_valid_cluster_id(1)
|
|
_assert_valid_cluster_id(2_147_483_647)
|
|
for bad in (0, -1, 2_147_483_648, 99_999_999_999):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_assert_valid_cluster_id(bad)
|
|
assert exc_info.value.status_code == 404
|
|
assert "Cluster not found" in exc_info.value.detail
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# stage_ssl_config_versions
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stage_creates_one_pending_version_per_cluster_with_ssl_naming():
|
|
import re
|
|
|
|
conn = _mk_conn()
|
|
conn.fetchval.return_value = 1001 # config_versions INSERT RETURNING id
|
|
|
|
with patch(
|
|
"services.haproxy_config.generate_haproxy_config_for_cluster",
|
|
new=AsyncMock(return_value="# cfg"),
|
|
):
|
|
results = await stage_ssl_config_versions(conn, 42, [1, 2], created_by=7)
|
|
|
|
assert len(results) == 2
|
|
assert all(r["success"] for r in results)
|
|
assert [r["cluster_id"] for r in results] == [1, 2]
|
|
|
|
insert_calls = [
|
|
c for c in conn.fetchval.call_args_list
|
|
if c.args and "INSERT INTO config_versions" in c.args[0]
|
|
]
|
|
assert len(insert_calls) == 2
|
|
for call in insert_calls:
|
|
sql = call.args[0]
|
|
assert "FALSE, 'PENDING'" in sql, "staged versions must be inactive + PENDING"
|
|
version_name = call.args[2]
|
|
# EXACT manual-flow scheme: Apply Management + has_pending_config
|
|
# LIKE-filters key off 'ssl-{id}-...'.
|
|
assert re.match(r"^ssl-42-create-\d+$", version_name), version_name
|
|
assert call.args[5] == 7 # created_by honours the importing user
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stage_reports_per_cluster_failure_without_raising():
|
|
conn = _mk_conn()
|
|
conn.fetchval.return_value = 1001
|
|
|
|
async def _gen(cluster_id):
|
|
if cluster_id == 2:
|
|
raise RuntimeError("config generation exploded")
|
|
return "# cfg"
|
|
|
|
with patch(
|
|
"services.haproxy_config.generate_haproxy_config_for_cluster",
|
|
new=AsyncMock(side_effect=_gen),
|
|
):
|
|
results = await stage_ssl_config_versions(conn, 42, [1, 2], created_by=7)
|
|
|
|
assert len(results) == 2
|
|
assert results[0]["success"] is True
|
|
assert results[1]["success"] is False
|
|
assert "exploded" in results[1]["error"]
|