🚧 Update certificate management
This commit is contained in:
@@ -116,7 +116,22 @@ def client(db_session, monkeypatch):
|
||||
def set_client_password(self, username, password):
|
||||
pass
|
||||
|
||||
def set_client_id(self, username, clientid=None):
|
||||
# Mirrors the real broker's setClientId: empty string unbinds.
|
||||
client = self.clients.get(username)
|
||||
if client is None:
|
||||
from app.dynsec import DynsecError
|
||||
|
||||
raise DynsecError(f"Client not found: {username}")
|
||||
client["clientid"] = clientid or ""
|
||||
|
||||
def get_client(self, username):
|
||||
# Mirrors the real broker: getClient on a missing client
|
||||
# returns a dynsec error, which the router maps to 404.
|
||||
if username not in self.clients:
|
||||
from app.dynsec import DynsecError
|
||||
|
||||
raise DynsecError(f"Client not found: {username}")
|
||||
return self.clients[username]
|
||||
|
||||
def create_group(self, groupname, roles=None):
|
||||
@@ -204,6 +219,11 @@ def client(db_session, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(mqtt_router, "dynsec_client", fake)
|
||||
|
||||
from app.routers import pki as pki_router
|
||||
|
||||
# The PKI client-cert endpoint validates the dynsec client exists.
|
||||
monkeypatch.setattr(pki_router, "dynsec_client", fake)
|
||||
|
||||
with TestClient(main_module.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
+173
-1
@@ -102,6 +102,34 @@ def test_password_reset_still_works_for_normal_clients(admin_client):
|
||||
assert len(body["password"]) >= 20 # random password, shown once
|
||||
|
||||
|
||||
def test_client_id_binding(admin_client):
|
||||
"""Bind/clear the expected client ID (spec section 11) and refuse
|
||||
it for the management identity."""
|
||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "device-1"})
|
||||
|
||||
bound = admin_client.put(
|
||||
"/api/v1/mqtt/clients/device-1/clientid",
|
||||
json={"clientid": "device-ps042"},
|
||||
)
|
||||
assert bound.status_code == 204
|
||||
client = admin_client.get("/api/v1/mqtt/clients/device-1").json()
|
||||
assert client["clientid"] == "device-ps042"
|
||||
|
||||
# Clearing with an empty string unbinds (any client id may connect).
|
||||
cleared = admin_client.put(
|
||||
"/api/v1/mqtt/clients/device-1/clientid", json={"clientid": ""}
|
||||
)
|
||||
assert cleared.status_code == 204
|
||||
client = admin_client.get("/api/v1/mqtt/clients/device-1").json()
|
||||
assert client["clientid"] in (None, "")
|
||||
|
||||
# The management identity is never manageable (existing guard).
|
||||
refused = admin_client.put(
|
||||
"/api/v1/mqtt/clients/admin/clientid", json={"clientid": "x"}
|
||||
)
|
||||
assert refused.status_code == 400
|
||||
|
||||
|
||||
def test_role_acl_lifecycle(admin_client):
|
||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||
|
||||
@@ -379,4 +407,148 @@ def test_server_certificate_is_cryptographically_valid(admin_client):
|
||||
assert int(inv["serial_number"]) == cert.serial_number
|
||||
from app.pki import fingerprint as pki_fingerprint
|
||||
|
||||
assert inv["fingerprint"] == pki_fingerprint(cert_pem)
|
||||
assert inv["fingerprint"] == pki_fingerprint(cert_pem)
|
||||
|
||||
|
||||
# --- Client certificates (spec sections 20, 24) ---------------------------------
|
||||
|
||||
|
||||
def test_client_certificate_issuance_and_binding(admin_client, db_session):
|
||||
"""Issue-client-cert requires an existing dynsec client; the bundle
|
||||
carries cert+key+CA exactly once and binds the identity link."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import MqttClientMetadata
|
||||
|
||||
ca_id = admin_client.post(
|
||||
"/api/v1/pki/ca",
|
||||
json={
|
||||
"common_name": "Client CA",
|
||||
"validity_days": 3650,
|
||||
"key_algorithm": "ecdsa-p256",
|
||||
},
|
||||
).json()["id"]
|
||||
|
||||
# Without the dynsec client it must 404, not issue a useless cert.
|
||||
missing = admin_client.post(
|
||||
"/api/v1/pki/certificates/client",
|
||||
json={"username": "ghost-device", "ca_id": ca_id, "validity_days": 365},
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
|
||||
response = admin_client.post(
|
||||
"/api/v1/pki/certificates/client",
|
||||
json={"username": "ps042", "ca_id": ca_id, "validity_days": 365},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
bundle = response.json()
|
||||
assert bundle["username"] == "ps042"
|
||||
assert "BEGIN CERTIFICATE" in bundle["certificate_pem"]
|
||||
assert "BEGIN PRIVATE KEY" in bundle["private_key_pem"]
|
||||
assert "BEGIN CERTIFICATE" in bundle["ca_chain_pem"]
|
||||
|
||||
# Identity binding recorded in metadata (spec section 24).
|
||||
meta = db_session.get(MqttClientMetadata, "ps042")
|
||||
assert meta is not None
|
||||
assert str(meta.certificate_id) == bundle["certificate_id"]
|
||||
|
||||
# Inventory shows the client cert with cert_type=client. The bundle
|
||||
# legitimately carries the private key ONCE (spec section 20); the
|
||||
# INVENTORY must never leak it.
|
||||
inventory = admin_client.get("/api/v1/pki/certificates")
|
||||
certs = inventory.json()
|
||||
entry = next(c for c in certs if c["id"] == bundle["certificate_id"])
|
||||
assert entry["cert_type"] == "client"
|
||||
assert entry["common_name"] == "ps042"
|
||||
# The bound MQTT identity is exposed for differentiation in the UI
|
||||
# (client certs list the device they authenticate, spec section 24).
|
||||
assert entry["username"] == "ps042"
|
||||
assert "PRIVATE KEY" not in inventory.text
|
||||
# Server certs carry no username.
|
||||
server_entries = [c for c in certs if c.get("cert_type") == "server"]
|
||||
assert all(c["username"] is None for c in server_entries)
|
||||
|
||||
|
||||
def test_client_cert_delete_unlinks_metadata(admin_client, db_session):
|
||||
from app.models import MqttClientMetadata
|
||||
|
||||
ca_id = admin_client.post(
|
||||
"/api/v1/pki/ca",
|
||||
json={"common_name": "Del CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256"},
|
||||
).json()["id"]
|
||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "unlink-me"})
|
||||
bundle = admin_client.post(
|
||||
"/api/v1/pki/certificates/client",
|
||||
json={"username": "unlink-me", "ca_id": ca_id, "validity_days": 365},
|
||||
).json()
|
||||
|
||||
deleted = admin_client.delete(
|
||||
f"/api/v1/pki/certificates/{bundle['certificate_id']}"
|
||||
)
|
||||
assert deleted.status_code == 204
|
||||
# The metadata row survives, unlinked (FK behaviour, mirrored in API).
|
||||
db_session.expire_all()
|
||||
meta = db_session.get(MqttClientMetadata, "unlink-me")
|
||||
assert meta is not None
|
||||
assert meta.certificate_id is None
|
||||
|
||||
|
||||
def test_listener_cert_binding_roundtrip(admin_client):
|
||||
"""Listener cert_id must round-trip through create/update (the gap that
|
||||
previously made TLS unusable from the API)."""
|
||||
ca_id = admin_client.post(
|
||||
"/api/v1/pki/ca",
|
||||
json={"common_name": "Listener CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256"},
|
||||
).json()["id"]
|
||||
cert_id = admin_client.post(
|
||||
"/api/v1/pki/certificates",
|
||||
json={"ca_id": ca_id, "common_name": "broker.local", "validity_days": 365},
|
||||
).json()["id"]
|
||||
|
||||
created = admin_client.post(
|
||||
"/api/v1/broker/listeners",
|
||||
json={
|
||||
"name": "TLS listener",
|
||||
"port": 8883,
|
||||
"protocol": "mqtt",
|
||||
"tls_enabled": True,
|
||||
"tls_min_version": "tlsv1.2",
|
||||
"require_certificate": True,
|
||||
"use_identity_as_username": True,
|
||||
"cert_id": cert_id,
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
listener = created.json()
|
||||
assert listener["cert_id"] == cert_id
|
||||
assert listener["require_certificate"] is True
|
||||
|
||||
# The generated config now emits the real certfile/keyfile/cafile.
|
||||
preview = admin_client.get("/api/v1/broker/config/preview").json()
|
||||
assert f"certfile /mosquitto/certs/{cert_id}.crt" in preview["config_text"]
|
||||
assert f"keyfile /mosquitto/certs/{cert_id}.key" in preview["config_text"]
|
||||
assert f"cafile /mosquitto/certs/{ca_id}.crt" in preview["config_text"]
|
||||
assert "require_certificate true" in preview["config_text"]
|
||||
assert "use_identity_as_username true" in preview["config_text"]
|
||||
|
||||
# Update keeps the binding (full-object PUT).
|
||||
updated = admin_client.put(
|
||||
f"/api/v1/broker/listeners/{listener['id']}",
|
||||
json={
|
||||
"name": "TLS listener",
|
||||
"port": 8883,
|
||||
"protocol": "mqtt",
|
||||
"tls_enabled": True,
|
||||
"tls_min_version": "tlsv1.2",
|
||||
"require_certificate": True,
|
||||
"use_identity_as_username": True,
|
||||
"cert_id": cert_id,
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["cert_id"] == cert_id
|
||||
|
||||
admin_client.delete(f"/api/v1/broker/listeners/{listener['id']}")
|
||||
admin_client.delete(f"/api/v1/pki/certificates/{cert_id}")
|
||||
admin_client.delete(f"/api/v1/pki/certificates/{ca_id}")
|
||||
@@ -64,6 +64,26 @@ def test_disabled_listeners_are_omitted(session):
|
||||
assert "listener 1884" not in result.config_text
|
||||
|
||||
|
||||
def test_unpublished_port_listener_warns(session):
|
||||
"""Listeners on ports not published by compose warn: they are only
|
||||
reachable inside the Docker network (spec section 5)."""
|
||||
session.add(ListenerConfig(name="internal-only", port=8884, protocol="mqtt"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert any(
|
||||
"not published to the host" in w and "8884" in w for w in result.warnings
|
||||
)
|
||||
|
||||
|
||||
def test_published_port_listener_does_not_warn(session):
|
||||
session.add(ListenerConfig(name="prod", port=8883, protocol="mqtt"))
|
||||
session.commit()
|
||||
|
||||
result = generate_config(session)
|
||||
assert not any("not published to the host" in w for w in result.warnings)
|
||||
|
||||
|
||||
def test_tls_listener_without_pki_warns(session):
|
||||
session.add(
|
||||
ListenerConfig(
|
||||
|
||||
Reference in New Issue
Block a user