From bfd2d86395865085bd0eb0ff10debf979e1348b3 Mon Sep 17 00:00:00 2001 From: Andrew Mirsky Date: Mon, 11 Aug 2025 17:38:50 -0400 Subject: [PATCH] removing superfluous dependency, updating readthedocs build process, plugin doc cleanup --- .readthedocs.yaml | 33 +++++++++++++++---------- amqtt/contrib/cert.py | 2 +- docs/plugins/auth_db.md | 4 +-- docs/plugins/cert.md | 35 +++++++++++++++++++++----- docs/plugins/contrib.md | 37 +++++++++++++++++----------- docs/plugins/http.md | 4 +-- docs/plugins/jwt.md | 50 ++++++++++++++++++++++++++++++++++++++ docs/plugins/ldap.md | 4 +-- docs/plugins/session.md | 4 +-- docs/plugins/shadows.md | 2 +- docs/scripts/exts.py | 6 +---- mkdocs.rtd.yml | 2 ++ pyproject.toml | 2 +- tests/conftest.py | 9 +++++++ tests/contrib/test_cert.py | 10 ++++---- tests/contrib/test_ldap.py | 36 ++++++++++++++++++++++++--- uv.lock | 10 ++++---- 17 files changed, 188 insertions(+), 62 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 62a145f..99e3927 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,15 +1,22 @@ - version: 2 +version: 2 - build: - os: "ubuntu-24.04" - tools: - python: "3.13" - jobs: - pre_install: - - pip install --upgrade pip - - pip install uv - - uv pip install --group dev --group docs - - uv run pytest +build: + os: "ubuntu-24.04" + tools: + python: "3.13" + apt_packages: + - libldap2-dev + - libsasl2-dev + jobs: + pre_install: + - pip install --upgrade pip + - pip install uv + - uv venv + - uv pip install --group dev --group docs ".[contrib]" + - uv run pytest --mock-docker=true + build: + html: + - uv run python -m mkdocs build --clean --site-dir $READTHEDOCS_OUTPUT/html --config-file mkdocs.rtd.yml - mkdocs: - configuration: mkdocs.rtd.yml +mkdocs: + configuration: mkdocs.rtd.yml diff --git a/amqtt/contrib/cert.py b/amqtt/contrib/cert.py index 879007d..1903dea 100644 --- a/amqtt/contrib/cert.py +++ b/amqtt/contrib/cert.py @@ -27,7 +27,7 @@ from amqtt.session import Session logger = logging.getLogger(__name__) -class CertificateAuthPlugin(BaseAuthPlugin): +class UserAuthCertPlugin(BaseAuthPlugin): """Used a *signed* x509 certificate's `Subject AlternativeName` or `SAN` to verify client authentication. Often used for IoT devices, this method provides the most secure form of identification. A root diff --git a/docs/plugins/auth_db.md b/docs/plugins/auth_db.md index b4620c1..f512e19 100644 --- a/docs/plugins/auth_db.md +++ b/docs/plugins/auth_db.md @@ -9,7 +9,7 @@ For ease of use, the [`user_mgr` command-line utility](auth_db.md/#user_mgr) to list clients. And the [`topic_mgr` command-line utility](auth_db.md/#topic_mgr) to add client access to subscribe, publish and receive messages on topics. -## Authentication Configuration +# Authentication Configuration ::: amqtt.contrib.auth_db.UserAuthDBPlugin.Config options: @@ -17,7 +17,7 @@ subscribe, publish and receive messages on topics. extra: class_style: "simple" -## Authorization Configuration +# Authorization Configuration ::: amqtt.contrib.auth_db.TopicAuthDBPlugin.Config options: diff --git a/docs/plugins/cert.md b/docs/plugins/cert.md index 4c07336..1349495 100644 --- a/docs/plugins/cert.md +++ b/docs/plugins/cert.md @@ -65,9 +65,32 @@ async def main(): asyncio.run(main()) ``` -::: amqtt.contrib.cert.CertificateAuthPlugin +## Background -### Root & Certificate Credentials +Often used for IoT devices, this method provides the most secure form of identification. A root +certificate, often referenced as a CA certificate -- either issued by a known authority (such as LetsEncrypt) +or a self-signed certificate) is used to sign a private key and certificate for the server. Each device/client +also gets a unique private key and certificate signed by the same CA certificate; also included in the device +certificate is a 'SAN' or SubjectAlternativeName which is the device's unique identifier. + +Since both server and device certificates are signed by the same CA certificate, the client can +verify the server's authenticity; and the server can verify the client's authenticity. And since +the device's certificate contains a x509 SAN, the server (with this plugin) can identify the device securely. + +!!! note "URI and Client ID configuration" + `uri_domain` configuration must be set to the same uri used to generate the device credentials + + when a device is connecting with private key and certificate, the `client_id` must + match the device id used to generate the device credentials. + +Available ore three scripts to help with the key generation and certificate signing: `ca_creds`, `server_creds` +and `device_creds`. + +!!! note "Configuring broker & client for using Self-signed root CA" + If using self-signed root credentials, the `cafile` configuration for both broker and client need to be + configured with `cafile` set to the `ca.crt`. + +## Root & Certificate Credentials The process for generating a server's private key and certificate is only done once. If you have a private key & certificate -- such as one from verifying your webserver's domain with LetsEncrypt -- that you want to use, pass them to the `server_creds` cli. @@ -99,7 +122,7 @@ flowchart LR ssi --> skc ``` -### Device credentials +## Device credentials For each device, create a device id to generate a device-specific private key and certificate using the `device_creds` cli. Use the same CA as was used for the server (above) so the client & server recognize each other. @@ -127,9 +150,9 @@ flowchart LR con["country, org
common name
& device id"] --> ccsr ``` -### Configuration +## Configuration -::: amqtt.contrib.cert.CertificateAuthPlugin.Config +::: amqtt.contrib.cert.UserAuthCertPlugin.Config options: show_source: false heading_level: 4 @@ -137,7 +160,7 @@ flowchart LR class_style: "simple" -### Key and Certificate Generation +## Key and Certificate Generation ::: mkdocs-typer2 :module: amqtt.scripts.ca_creds diff --git a/docs/plugins/contrib.md b/docs/plugins/contrib.md index 87c8e11..79df63d 100644 --- a/docs/plugins/contrib.md +++ b/docs/plugins/contrib.md @@ -5,22 +5,21 @@ These are fully supported plugins but require additional dependencies to be inst `$ pip install '.[contrib]'` -- Relational Database Auth
- _includes manager script to add, remove and create db entries_ - - [DB Client Authentication](auth_db.md)
- Authenticate a client's connection to broker based on entries in a relational db (mysql, postgres, maria, sqlite).
- `amqtt.contrib.auth_db.AuthUserDBPlugin` - - [DB Client Authorization](auth_db.md)
- Determine a client's access to topics.
- `amqtt.contrib.auth_db.AuthTopicDBPlugin` +- [Relational Database Auth](auth_db.md)
+ Grant or deny access to clients based on entries in a relational db (mysql, postgres, maria, sqlite). _Includes + manager script to add, remove and create db entries_
+ - `amqtt.contrib.auth_db.UserAuthDBPlugin` + - `amqtt.contrib.auth_db.TopicAuthDBPlugin` - [HTTP Auth](http.md)
- Determine client authentication and authorization based on response from a separate HTTP server.
- `amqtt.contrib.http.HttpAuthTopicPlugin` + Determine client authentication and/or authorization based on response from a separate HTTP server.
+ - `amqtt.contrib.http.UserAuthHttpPlugin` + - `amqtt.contrib.http.TopicAuthHttpPlugin` - [LDAP Auth](ldap.md)
Authenticate a user with an LDAP directory server.
- `amqtt.contrib.ldap.LDAPAuthPlugin` + - `amqtt.contrib.ldap.UserAuthLdapPlugin` + - `amqtt.contrib.ldap.TopicAuthLdapPlugin` - [Shadows](shadows.md)
Device shadows provide a persistent, cloud-based representation of the state of a device, @@ -31,6 +30,16 @@ These are fully supported plugins but require additional dependencies to be inst - [Certificate Auth](cert.md)
Using client-specific certificates, signed by a common authority (even if self-signed) provides a highly secure way of authenticating mqtt clients. Often used with IoT devices where a unique - certificate can be initialized on initial provisioning. Includes command line utilities to generate - root, broker and device certificates with the correct X509 attributes to enable authenticity.
- `amqtt.contrib.cert.CertificateAuthPlugin.Config` + certificate can be initialized on initial provisioning. _Includes command line utilities to generate + root, broker and device certificates with the correct X509 + attributes to enable authenticity._
+ `amqtt.contrib.cert.UserAuthCertPlugin` + +- [JWT Auth](jwt.md)
+ Plugin to determine user authentication and topic authorization based on claims in a JWT. + - `amqtt.contrib.jwt.UserAuthJwtPlugin` (client authentication) + - `amqtt.contrib.jwt.TopicAuthJwtPlugin` (topic authorization) + +- [Session Persistence](session.md)
+ Plugin to store session information and retained topic messages in the event that the broker terminates abnormally.
+ `amqtt.contrib.persistence.SessionDBPlugin` diff --git a/docs/plugins/http.md b/docs/plugins/http.md index 9ef639c..2e1d69d 100644 --- a/docs/plugins/http.md +++ b/docs/plugins/http.md @@ -8,7 +8,7 @@ that respond with information about client authentication and/or topic-level aut Configuration of these plugins is identical (except for the uri name) so that they can be used independently, if desired. -## User Auth +# User Auth See the [Request and Response Modes](#request-response-modes) section below for details on `params_mode` and `response_mode`. @@ -49,7 +49,7 @@ See the [Request and Response Modes](#request-response-modes) section below for extra: class_style: "simple" -## Topic ACL +# Topic ACL See the [Request and Response Modes](#request-response-modes) section below for details on `params_mode` and `response_mode`. diff --git a/docs/plugins/jwt.md b/docs/plugins/jwt.md index e69de29..63edcb8 100644 --- a/docs/plugins/jwt.md +++ b/docs/plugins/jwt.md @@ -0,0 +1,50 @@ +# Authentication & Authorization from JWT + +- `amqtt.contrib.jwt.UserAuthJwtPlugin` (client authentication) +- `amqtt.contrib.jwt.TopicAuthJwtPlugin` (topic authorization) + +Plugin to determine user authentication and topic authorization based on claims in a JWT. + +# User Authentication + +For auth, the JWT should include a key as specified in the configuration as `user_clam`: + +```python +from datetime import datetime, UTC, timedelta +claims = { + "username": "example_user", + "exp": datetime.now(UTC) + timedelta(hours=1), +} +``` + +::: amqtt.contrib.jwt.UserAuthJwtPlugin.Config + options: + show_source: false + heading_level: 4 + extra: + class_style: "simple" + + +# Topic Authorization + +For authorizing a client for certain topics, the token should also include claims for publish, subscribe and receive; +keys based on how `publish_claim`, `subscribe_claim` and `receive_claim` are specified in the plugin's configuration. + +```python +from datetime import datetime, UTC, timedelta + + claims = { + "username": "example_user", + "exp": datetime.now(UTC) + timedelta(hours=1), + "publish_acl": ['my/topic/#', 'my/+/other'], + "subscribe_acl": ['my/+/other'], + "receive_acl": ['#'] + } +``` + +::: amqtt.contrib.jwt.TopicAuthJwtPlugin.Config + options: + show_source: false + heading_level: 4 + extra: + class_style: "simple" diff --git a/docs/plugins/ldap.md b/docs/plugins/ldap.md index 229f254..9ff83e9 100644 --- a/docs/plugins/ldap.md +++ b/docs/plugins/ldap.md @@ -8,7 +8,7 @@ for client authentication and/or topic-level authorization. Authenticate a user with an LDAP directory server. -## User Auth +# User Auth ::: amqtt.contrib.ldap.UserAuthLdapPlugin.Config options: @@ -16,7 +16,7 @@ Authenticate a user with an LDAP directory server. extra: class_style: "simple" -## Topic Auth (ACL) +# Topic Auth (ACL) ::: amqtt.contrib.ldap.TopicAuthLdapPlugin.Config options: diff --git a/docs/plugins/session.md b/docs/plugins/session.md index a9f45ea..753a7a0 100644 --- a/docs/plugins/session.md +++ b/docs/plugins/session.md @@ -1,10 +1,10 @@ # Session Persistence -`amqtt.plugins.persistence.SessionDBPlugin` +`amqtt.contrib.persistence.SessionDBPlugin` Plugin to store session information and retained topic messages in the event that the broker terminates abnormally. -::: amqtt.plugins.persistence.SessionDBPlugin.Config +::: amqtt.contrib.persistence.SessionDBPlugin.Config options: show_source: false heading_level: 4 diff --git a/docs/plugins/shadows.md b/docs/plugins/shadows.md index c1e6bb1..5f5421a 100644 --- a/docs/plugins/shadows.md +++ b/docs/plugins/shadows.md @@ -171,7 +171,7 @@ state (last row in table). | `{ "levels": [1, 10, 4]}` | `{"levels": [1, 4, 10]}` | `{"levels": [1, 4, 10]}` | | `{ "brightness": 100, "mode": "eco" }` | `{ "brightness": 80 }` | `{ "brightness": 80, "mode": null }` | -### Configuration +## Configuration ::: amqtt.contrib.shadows.ShadowPlugin.Config options: diff --git a/docs/scripts/exts.py b/docs/scripts/exts.py index 0f0bcdc..0677ef2 100644 --- a/docs/scripts/exts.py +++ b/docs/scripts/exts.py @@ -1,13 +1,9 @@ import ast -import json import pprint from typing import Any import griffe -from _griffe.agents.inspector import Inspector -from _griffe.agents.nodes.runtime import ObjectNode -from _griffe.agents.visitor import Visitor -from _griffe.models import Attribute +from griffe import Inspector, ObjectNode, Visitor, Attribute from amqtt.contexts import default_listeners, default_broker_plugins, default_client_plugins from amqtt.contrib.auth_db.plugin import default_hash_scheme diff --git a/mkdocs.rtd.yml b/mkdocs.rtd.yml index 2157c8a..3a40ec4 100644 --- a/mkdocs.rtd.yml +++ b/mkdocs.rtd.yml @@ -50,6 +50,8 @@ nav: - LDAP Auth: plugins/ldap.md - Shadows: plugins/shadows.md - Certificate Auth: plugins/cert.md + - JWT Auth: plugins/jwt.md + - Session persistence: plugins/session.md - Reference: - Containerization: docker.md - Support: support.md diff --git a/pyproject.toml b/pyproject.toml index 6dfe6d3..6cc814e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dependencies = [ "passlib==1.7.4", # https://pypi.org/project/passlib "PyYAML==6.0.2", # https://pypi.org/project/PyYAML "typer==0.15.4", - "aiohttp>=3.12.7", "dacite>=1.9.2", "psutil>=7.0.0" ] @@ -67,6 +66,7 @@ dev = [ ] docs = [ + "griffe>=1.11.1", "markdown-callouts>=0.4", "markdown-exec>=1.8", "mkdocs>=1.6", diff --git a/tests/conftest.py b/tests/conftest.py index 2b40a1a..14fea72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -155,3 +155,12 @@ def ca_file_fixture(): for file in temp_dir.iterdir(): file.unlink() temp_dir.rmdir() + + +def pytest_addoption(parser): + parser.addoption( + "--mock-docker", + action="store", + default="false", + help="for environments where docker isn't available, mock calls which require docker", + ) \ No newline at end of file diff --git a/tests/contrib/test_cert.py b/tests/contrib/test_cert.py index 27f51ee..c56155e 100644 --- a/tests/contrib/test_cert.py +++ b/tests/contrib/test_cert.py @@ -13,7 +13,7 @@ import pytest from amqtt.broker import BrokerContext, Broker from amqtt.client import MQTTClient -from amqtt.contrib.cert import CertificateAuthPlugin +from amqtt.contrib.cert import UserAuthCertPlugin from amqtt.errors import ConnectError from amqtt.scripts.server_creds import server_creds as get_server_creds from amqtt.scripts.device_creds import device_creds as get_device_creds @@ -99,9 +99,9 @@ async def test_cert_plugin(ssl_object_mock, uri_domain, client_id, expected_resu } bc = BrokerContext(broker=Broker(config=empty_cfg)) - bc.config = CertificateAuthPlugin.Config(uri_domain=uri_domain) + bc.config = UserAuthCertPlugin.Config(uri_domain=uri_domain) - cert_auth_plugin = CertificateAuthPlugin(bc) + cert_auth_plugin = UserAuthCertPlugin(bc) s = Session() s.client_id = client_id @@ -128,7 +128,7 @@ async def test_client_broker_cert_authentication(ca_creds, server_creds, device_ }, 'plugins': { 'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{}, - 'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'}, + 'amqtt.contrib.cert.UserAuthCertPlugin': {'uri_domain': 'test.amqtt.io'}, } } @@ -187,7 +187,7 @@ async def test_client_broker_wrong_certs(ca_creds, server_creds, device_creds): }, 'plugins': { 'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{}, - 'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'}, + 'amqtt.contrib.cert.UserAuthCertPlugin': {'uri_domain': 'test.amqtt.io'}, } } diff --git a/tests/contrib/test_ldap.py b/tests/contrib/test_ldap.py index 9f6803a..1360b1a 100644 --- a/tests/contrib/test_ldap.py +++ b/tests/contrib/test_ldap.py @@ -1,17 +1,17 @@ import asyncio +import ldap import time from pathlib import Path +from unittest.mock import patch, MagicMock import pytest from amqtt.broker import BrokerContext, Broker from amqtt.client import MQTTClient from amqtt.contexts import BrokerConfig, ListenerConfig, ClientConfig, Action -from amqtt.contrib.auth_db.user_mgr_cli import user_app from amqtt.contrib.ldap import UserAuthLdapPlugin, TopicAuthLdapPlugin from amqtt.errors import ConnectError from amqtt.session import Session -from tests.test_cli import broker # Pin the project name to avoid creating multiple stacks @@ -29,7 +29,7 @@ def docker_compose_file(pytestconfig): return Path(pytestconfig.rootdir) / "tests/fixtures/ldap" / "docker-compose.yml" @pytest.fixture(scope="session") -def ldap_service(docker_ip, docker_services): +def ldap_service_docker(docker_ip, docker_services): """Ensure that HTTP service is up and responsive.""" # `port_for` takes a container port and returns the corresponding host port @@ -38,6 +38,36 @@ def ldap_service(docker_ip, docker_services): time.sleep(2) return url +@pytest.fixture(scope="session") +def ldap_service_mock(): + """Ensure that HTTP service is up and responsive.""" + + # `port_for` takes a container port and returns the corresponding host port + url = "ldap://localhost:0" + mock_ldap_object = MagicMock() + + def mock_simple_s(*args, **kwargs): + return [ ('dn', {'uid':'jdoe', 'publishACL':[b'my/topic/one', b'my/topic/two']}), ] + + def mock_simple_bind_s(*args): + p = args[1] + if p in ("adminpassword", "johndoepassword"): + return + raise ldap.INVALID_CREDENTIALS + + mock_ldap_object.search_s.side_effect = mock_simple_s + mock_ldap_object.simple_bind_s.side_effect = mock_simple_bind_s + with patch("ldap.initialize", return_value=mock_ldap_object): + yield url + +@pytest.fixture(scope="session") +def ldap_service(request): + mode = request.config.getoption("--mock-docker") + if mode: + return request.getfixturevalue("ldap_service_mock") + return request.getfixturevalue("ldap_service_docker") + + @pytest.mark.asyncio async def test_ldap_user_plugin(ldap_service): ctx = BrokerContext(Broker()) diff --git a/uv.lock b/uv.lock index b22c439..c257946 100644 --- a/uv.lock +++ b/uv.lock @@ -132,7 +132,6 @@ name = "amqtt" version = "0.11.3" source = { editable = "." } dependencies = [ - { name = "aiohttp" }, { name = "dacite" }, { name = "passlib" }, { name = "psutil" }, @@ -188,6 +187,7 @@ dev = [ { name = "types-setuptools" }, ] docs = [ + { name = "griffe" }, { name = "markdown-callouts" }, { name = "markdown-exec" }, { name = "mkdocs" }, @@ -207,7 +207,6 @@ docs = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.12.7" }, { name = "aiohttp", marker = "extra == 'contrib'", specifier = ">=3.12.13" }, { name = "aiosqlite", marker = "extra == 'contrib'", specifier = ">=0.21.0" }, { name = "argon2-cffi", marker = "extra == 'contrib'", specifier = ">=25.1.0" }, @@ -258,6 +257,7 @@ dev = [ { name = "types-setuptools", specifier = ">=78.1.0.20250329" }, ] docs = [ + { name = "griffe", specifier = ">=1.11.1" }, { name = "markdown-callouts", specifier = ">=0.4" }, { name = "markdown-exec", specifier = ">=1.8" }, { name = "mkdocs", specifier = ">=1.6" }, @@ -936,14 +936,14 @@ wheels = [ [[package]] name = "griffe" -version = "1.7.3" +version = "1.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/0f/9cbd56eb047de77a4b93d8d4674e70cd19a1ff64d7410651b514a1ed93d5/griffe-1.11.1.tar.gz", hash = "sha256:d54ffad1ec4da9658901eb5521e9cddcdb7a496604f67d8ae71077f03f549b7e", size = 410996, upload-time = "2025-08-11T11:38:35.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a3/451ffd422ce143758a39c0290aaa7c9727ecc2bcc19debd7a8f3c6075ce9/griffe-1.11.1-py3-none-any.whl", hash = "sha256:5799cf7c513e4b928cfc6107ee6c4bc4a92e001f07022d97fd8dee2f612b6064", size = 138745, upload-time = "2025-08-11T11:38:33.964Z" }, ] [[package]]