kopia lustrzana https://github.com/Yakifo/amqtt
Plugin: authenticate against LDAP server (#287)
* Yakifo/amqtt#261 : plugin for authenticating a user with ldap. * adding openldap dependencies * updating ldap custom schema to include three ACL attributes, retrieve the correct topic list and check if topic is allowedpull/289/head^2
rodzic
e6b0f1d002
commit
beb8672116
|
@ -32,6 +32,9 @@ jobs:
|
|||
cache-local-path: ${{ env.UV_CACHE_DIR }}
|
||||
python-version: "3.13"
|
||||
|
||||
- name: install openldap dependencies
|
||||
run: sudo apt-get install -y libldap2-dev libsasl2-dev
|
||||
|
||||
- name: 🏗 Install the project
|
||||
run: uv sync --locked --dev --all-extras
|
||||
|
||||
|
@ -68,6 +71,9 @@ jobs:
|
|||
cache-local-path: ${{ env.UV_CACHE_DIR }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: install openldap dependencies
|
||||
run: sudo apt-get install -y libldap2-dev libsasl2-dev
|
||||
|
||||
- name: 🏗 Install the project
|
||||
run: uv sync --locked --dev --all-extras
|
||||
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import ClassVar
|
||||
|
||||
import ldap
|
||||
|
||||
from amqtt.broker import BrokerContext
|
||||
from amqtt.contexts import Action
|
||||
from amqtt.errors import PluginInitError
|
||||
from amqtt.plugins import TopicMatcher
|
||||
from amqtt.plugins.base import BaseAuthPlugin, BaseTopicPlugin, BasePlugin
|
||||
from amqtt.session import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LdapConfig:
|
||||
"""Configuration for the LDAP Plugins."""
|
||||
|
||||
server: str
|
||||
"""uri formatted server location. e.g `ldap://localhost:389`"""
|
||||
base_dn: str
|
||||
"""distinguished name (dn) of the ldap server. e.g. `dc=amqtt,dc=io`"""
|
||||
user_attribute: str
|
||||
"""attribute in ldap entry to match the username against"""
|
||||
bind_dn: str
|
||||
"""distinguished name (dn) of known, preferably read-only, user. e.g. `cn=admin,dc=amqtt,dc=io`"""
|
||||
bind_password: str
|
||||
"""password for known, preferably read-only, user"""
|
||||
|
||||
|
||||
class AuthLdapPlugin(BasePlugin[BrokerContext]):
|
||||
|
||||
def __init__(self, context: BrokerContext) -> None:
|
||||
super().__init__(context)
|
||||
|
||||
self.conn = ldap.initialize(self.config.server)
|
||||
self.conn.protocol_version = ldap.VERSION3 # pylint: disable=E1101
|
||||
try:
|
||||
self.conn.simple_bind_s(self.config.bind_dn, self.config.bind_password)
|
||||
except ldap.INVALID_CREDENTIALS as e: # pylint: disable=E1101
|
||||
raise PluginInitError(self.__class__) from e
|
||||
|
||||
class UserAuthLdapPlugin(AuthLdapPlugin, BaseAuthPlugin):
|
||||
"""Plugin to authenticate a user with an LDAP directory server."""
|
||||
|
||||
async def authenticate(self, *, session: Session) -> bool | None:
|
||||
|
||||
# use our initial creds to see if the user exists
|
||||
search_filter = f"({self.config.user_attribute}={session.username})"
|
||||
result = self.conn.search_s(self.config.base_dn, ldap.SCOPE_SUBTREE, search_filter, ["dn"]) # pylint: disable=E1101
|
||||
if not result:
|
||||
logger.debug(f"user not found: {session.username}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# `search_s` responds with list of tuples: (dn, entry); first in list is our match
|
||||
user_dn = result[0][0]
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
try:
|
||||
user_conn = ldap.initialize(self.config.server)
|
||||
user_conn.simple_bind_s(user_dn, session.password)
|
||||
except ldap.INVALID_CREDENTIALS: # pylint: disable=E1101
|
||||
logger.debug(f"invalid credentials for '{session.username}'")
|
||||
return False
|
||||
except ldap.LDAPError as e: # pylint: disable=E1101
|
||||
logger.debug(f"LDAP error during user bind: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@dataclass
|
||||
class Config(LdapConfig):
|
||||
"""Configuration for the User Auth LDAP Plugin."""
|
||||
|
||||
class TopicAuthLdapPlugin(AuthLdapPlugin, BaseTopicPlugin):
|
||||
"""Plugin to authenticate a user with an LDAP directory server."""
|
||||
|
||||
_action_attr_map: ClassVar = {
|
||||
Action.PUBLISH: "publish_attribute",
|
||||
Action.SUBSCRIBE: "subscribe_attribute",
|
||||
Action.RECEIVE: "receive_attribute"
|
||||
}
|
||||
|
||||
def __init__(self, context: BrokerContext) -> None:
|
||||
super().__init__(context)
|
||||
|
||||
self.topic_matcher = TopicMatcher()
|
||||
|
||||
async def topic_filtering(
|
||||
self, *, session: Session | None = None, topic: str | None = None, action: Action | None = None
|
||||
) -> bool | None:
|
||||
|
||||
# if not provided needed criteria, can't properly evaluate topic filtering
|
||||
if not session or not action or not topic:
|
||||
return None
|
||||
|
||||
search_filter = f"({self.config.user_attribute}={session.username})"
|
||||
attrs = [
|
||||
"cn",
|
||||
self.config.publish_attribute,
|
||||
self.config.subscribe_attribute,
|
||||
self.config.receive_attribute
|
||||
]
|
||||
results = self.conn.search_s(self.config.base_dn, ldap.SCOPE_SUBTREE, search_filter, attrs) # pylint: disable=E1101
|
||||
|
||||
|
||||
if not results:
|
||||
logger.debug(f"user not found: {session.username}")
|
||||
return False
|
||||
|
||||
if len(results) > 1:
|
||||
found_users = [dn for dn, _ in results]
|
||||
logger.debug(f"multiple users found: {', '.join(found_users)}")
|
||||
return False
|
||||
|
||||
dn, entry = results[0]
|
||||
|
||||
ldap_attribute = getattr(self.config, self._action_attr_map[action])
|
||||
topic_filters = [t.decode("utf-8") for t in entry.get(ldap_attribute, [])]
|
||||
logger.debug(f"DN: {dn} - {ldap_attribute}={topic_filters}")
|
||||
|
||||
return self.topic_matcher.are_topics_allowed(topic, topic_filters)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config(LdapConfig):
|
||||
"""Configuration for the LDAPAuthPlugin."""
|
||||
|
||||
publish_attribute: str
|
||||
"""LDAP attribute which contains a list of permissible publish topics."""
|
||||
subscribe_attribute: str
|
||||
"""LDAP attribute which contains a list of permissible subscribe topics."""
|
||||
receive_attribute: str
|
||||
"""LDAP attribute which contains a list of permissible receive topics."""
|
|
@ -32,3 +32,7 @@ class TopicMatcher:
|
|||
.lstrip("?"))
|
||||
match_pattern = self._topic_filter_matchers[a_filter]
|
||||
return bool(match_pattern.fullmatch(topic))
|
||||
|
||||
def are_topics_allowed(self, topic: str, many_filters: list[str]) -> bool:
|
||||
|
||||
return any(self.is_topic_allowed(topic, a_filter) for a_filter in many_filters)
|
||||
|
|
|
@ -18,6 +18,10 @@ These are fully supported plugins but require additional dependencies to be inst
|
|||
Determine client authentication and authorization based on response from a separate HTTP server.<br/>
|
||||
`amqtt.contrib.http.HttpAuthTopicPlugin`
|
||||
|
||||
- [LDAP Auth](ldap.md)<br/>
|
||||
Authenticate a user with an LDAP directory server.<br/>
|
||||
`amqtt.contrib.ldap.LDAPAuthPlugin`
|
||||
|
||||
- [Shadows](shadows.md)<br/>
|
||||
Device shadows provide a persistent, cloud-based representation of the state of a device,
|
||||
even when the device is offline. This plugin tracks the desired and reported state of a client
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# Authentication with LDAP Server
|
||||
|
||||
If clients accessing the broker are managed by an LDAP server, this plugin can verify credentials
|
||||
for client authentication and/or topic-level authorization.
|
||||
|
||||
- `amqtt.contrib.ldap.UserAuthLdapPlugin` (client authentication)
|
||||
- `amqtt.contrib.ldap.TopicAuthLdapPlugin` (topic authorization)
|
||||
|
||||
Authenticate a user with an LDAP directory server.
|
||||
|
||||
## User Auth
|
||||
|
||||
::: amqtt.contrib.ldap.UserAuthLdapPlugin.Config
|
||||
options:
|
||||
heading_level: 4
|
||||
extra:
|
||||
class_style: "simple"
|
||||
|
||||
## Topic Auth (ACL)
|
||||
|
||||
::: amqtt.contrib.ldap.TopicAuthLdapPlugin.Config
|
||||
options:
|
||||
heading_level: 4
|
||||
extra:
|
||||
class_style: "simple"
|
|
@ -47,6 +47,7 @@ nav:
|
|||
- plugins/contrib.md
|
||||
- Database Auth: plugins/auth_db.md
|
||||
- HTTP Auth: plugins/http.md
|
||||
- LDAP Auth: plugins/ldap.md
|
||||
- Shadows: plugins/shadows.md
|
||||
- Certificate Auth: plugins/cert.md
|
||||
- Reference:
|
||||
|
|
|
@ -39,9 +39,6 @@ dependencies = [
|
|||
]
|
||||
|
||||
[dependency-groups]
|
||||
contrib = [
|
||||
"pyopenssl>=25.1.0",
|
||||
]
|
||||
dev = [
|
||||
"aiosqlite>=0.21.0",
|
||||
"greenlet>=3.2.3",
|
||||
|
@ -57,6 +54,7 @@ dev = [
|
|||
"pyopenssl>=25.1.0",
|
||||
"pytest-asyncio>=0.26.0", # https://pypi.org/project/pytest-asyncio
|
||||
"pytest-cov>=6.1.0", # https://pypi.org/project/pytest-cov
|
||||
"pytest-docker>=3.2.3",
|
||||
"pytest-logdog>=0.1.0", # https://pypi.org/project/pytest-logdog
|
||||
"pytest-timeout>=2.3.1", # https://pypi.org/project/pytest-timeout
|
||||
"pytest>=8.3.5", # https://pypi.org/project/pytest
|
||||
|
@ -96,8 +94,10 @@ contrib = [
|
|||
"sqlalchemy[asyncio]>=2.0.41",
|
||||
"argon2-cffi>=25.1.0",
|
||||
"aiohttp>=3.12.13",
|
||||
"python-ldap>=3.4.4",
|
||||
"mergedeep>=1.3.4",
|
||||
"jsonschema>=4.25.0",
|
||||
"pyopenssl>=25.1.0"
|
||||
]
|
||||
|
||||
|
||||
|
@ -221,7 +221,7 @@ max-returns = 10
|
|||
addopts = ["--cov=amqtt", "--cov-report=term-missing", "--cov-report=html"]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
timeout = 10
|
||||
timeout = 15
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
#addopts = ["--tb=short", "--capture=tee-sys"]
|
||||
#log_cli = true
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
@pytest.fixture(scope="session")
|
||||
def docker_compose_project_name() -> str:
|
||||
return "openldap"
|
||||
|
||||
# Stop the stack before starting a new one
|
||||
@pytest.fixture(scope="session")
|
||||
def docker_setup():
|
||||
return ["down -v", "up --build -d"]
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
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):
|
||||
"""Ensure that HTTP service is up and responsive."""
|
||||
|
||||
# `port_for` takes a container port and returns the corresponding host port
|
||||
port = docker_services.port_for("openldap", 389)
|
||||
url = "ldap://{}:{}".format(docker_ip, port)
|
||||
time.sleep(2)
|
||||
return url
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ldap_user_plugin(ldap_service):
|
||||
ctx = BrokerContext(Broker())
|
||||
ctx.config = UserAuthLdapPlugin.Config(
|
||||
server=ldap_service,
|
||||
base_dn="dc=amqtt,dc=io",
|
||||
user_attribute="uid",
|
||||
bind_dn="cn=admin,dc=amqtt,dc=io",
|
||||
bind_password="adminpassword",
|
||||
)
|
||||
ldap_plugin = UserAuthLdapPlugin(context=ctx)
|
||||
|
||||
s = Session()
|
||||
s.username = "jdoe"
|
||||
s.password = "johndoepassword"
|
||||
|
||||
assert await ldap_plugin.authenticate(session=s), "could not authenticate user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ldap_user(ldap_service):
|
||||
|
||||
cfg = BrokerConfig(
|
||||
listeners={ 'default' : ListenerConfig() },
|
||||
plugins={
|
||||
'amqtt.contrib.ldap.UserAuthLdapPlugin': {
|
||||
'server': ldap_service,
|
||||
'base_dn': 'dc=amqtt,dc=io',
|
||||
'user_attribute': 'uid',
|
||||
'bind_dn': 'cn=admin,dc=amqtt,dc=io',
|
||||
'bind_password': 'adminpassword',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
broker = Broker(config=cfg)
|
||||
await broker.start()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
client = MQTTClient(config=ClientConfig(auto_reconnect=False))
|
||||
await client.connect('mqtt://jdoe:johndoepassword@127.0.0.1:1883')
|
||||
await asyncio.sleep(0.1)
|
||||
await client.publish('my/topic', b'my message')
|
||||
await asyncio.sleep(0.1)
|
||||
await client.disconnect()
|
||||
await broker.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ldap_user_invalid_creds(ldap_service):
|
||||
|
||||
cfg = BrokerConfig(
|
||||
listeners={ 'default' : ListenerConfig() },
|
||||
plugins={
|
||||
'amqtt.contrib.ldap.UserAuthLdapPlugin': {
|
||||
'server': ldap_service,
|
||||
'base_dn': 'dc=amqtt,dc=io',
|
||||
'user_attribute': 'uid',
|
||||
'bind_dn': 'cn=admin,dc=amqtt,dc=io',
|
||||
'bind_password': 'adminpassword',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
broker = Broker(config=cfg)
|
||||
await broker.start()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
client = MQTTClient(config=ClientConfig(auto_reconnect=False))
|
||||
with pytest.raises(ConnectError):
|
||||
await client.connect('mqtt://jdoe:wrongpassword@127.0.0.1:1883')
|
||||
|
||||
await broker.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ldap_topic_plugin(ldap_service):
|
||||
ctx = BrokerContext(Broker())
|
||||
ctx.config = TopicAuthLdapPlugin.Config(
|
||||
server=ldap_service,
|
||||
base_dn="dc=amqtt,dc=io",
|
||||
user_attribute="uid",
|
||||
bind_dn="cn=admin,dc=amqtt,dc=io",
|
||||
bind_password="adminpassword",
|
||||
publish_attribute="publishACL",
|
||||
subscribe_attribute="subscribeACL",
|
||||
receive_attribute="receiveACL"
|
||||
)
|
||||
ldap_plugin = TopicAuthLdapPlugin(context=ctx)
|
||||
|
||||
s = Session()
|
||||
s.username = "jdoe"
|
||||
s.password = "wrongpassword"
|
||||
|
||||
assert await ldap_plugin.topic_filtering(session=s, topic='my/topic/one', action=Action.PUBLISH), "access not granted"
|
|
@ -0,0 +1,7 @@
|
|||
attributetype ( 1.3.6.1.4.1.4203.666.1.1 NAME 'publishACL' DESC 'topics for publishing' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
|
||||
|
||||
attributetype ( 1.3.6.1.4.1.4203.666.1.2 NAME 'subscribeACL' DESC 'topics for subscribing' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
|
||||
|
||||
attributetype ( 1.3.6.1.4.1.4203.666.1.3 NAME 'receiveACL' DESC 'topics for receiving' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15)
|
||||
|
||||
objectclass ( 1.3.6.1.4.1.4203.666.2.1 NAME 'customuserinfo' DESC 'User info with custom attributes' SUP inetOrgPerson STRUCTURAL MUST ( cn $ sn ) MAY ( publishACL $ subscribeACL $ receiveACL ) )
|
|
@ -0,0 +1,18 @@
|
|||
dn: ou=users,dc=amqtt,dc=io
|
||||
objectClass: organizationalUnit
|
||||
ou: users
|
||||
description: Organizational Unit for storing user entries
|
||||
|
||||
dn: uid=jdoe,ou=users,dc=amqtt,dc=io
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: customuserinfo
|
||||
cn: John Doe
|
||||
sn: Doe
|
||||
uid: jdoe
|
||||
mail: jdoe@amqtt.io
|
||||
# `slappasswd -s johndoepassword`
|
||||
userPassword: {SSHA}ANVSnjfMu85vXHNS5XW7i4EHGJ8VjMtu
|
||||
publishACL: my/topic/#
|
||||
publishACL: other/+/topic
|
||||
subscribeACL: my/topic/two
|
||||
receiveACL: my/topic/three
|
|
@ -0,0 +1,22 @@
|
|||
version: '3'
|
||||
services:
|
||||
openldap:
|
||||
image: osixia/openldap:latest
|
||||
container_name: openldap
|
||||
command: [--copy-service]
|
||||
restart: always
|
||||
volumes:
|
||||
- ./customuser.schema:/container/service/slapd/assets/config/bootstrap/schema/customuser.schema
|
||||
- ./customusers.ldif:/container/service/slapd/assets/config/bootstrap/ldif/50-customusers.ldif
|
||||
- ldap-data:/var/lib/ldap
|
||||
- ldap-config:/etc/ldap/slapd.d
|
||||
ports:
|
||||
- "1389:389"
|
||||
- "1636:636"
|
||||
environment:
|
||||
- LDAP_ADMIN_PASSWORD=adminpassword
|
||||
- LDAP_DOMAIN=amqtt.io
|
||||
|
||||
volumes:
|
||||
ldap-data:
|
||||
ldap-config:
|
54
uv.lock
54
uv.lock
|
@ -154,13 +154,12 @@ contrib = [
|
|||
{ name = "greenlet" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mergedeep" },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "python-ldap" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
contrib = [
|
||||
{ name = "pyopenssl" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "greenlet" },
|
||||
|
@ -177,6 +176,7 @@ dev = [
|
|||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-docker" },
|
||||
{ name = "pytest-logdog" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
|
@ -218,6 +218,8 @@ requires-dist = [
|
|||
{ name = "mergedeep", marker = "extra == 'contrib'", specifier = ">=1.3.4" },
|
||||
{ name = "passlib", specifier = "==1.7.4" },
|
||||
{ name = "psutil", specifier = ">=7.0.0" },
|
||||
{ name = "pyopenssl", marker = "extra == 'contrib'", specifier = ">=25.1.0" },
|
||||
{ name = "python-ldap", marker = "extra == 'contrib'", specifier = ">=3.4.4" },
|
||||
{ name = "pyyaml", specifier = "==6.0.2" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'contrib'", specifier = ">=2.0.41" },
|
||||
{ name = "transitions", specifier = "==0.9.2" },
|
||||
|
@ -227,7 +229,6 @@ requires-dist = [
|
|||
provides-extras = ["ci", "contrib"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
contrib = [{ name = "pyopenssl", specifier = ">=25.1.0" }]
|
||||
dev = [
|
||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||
{ name = "greenlet", specifier = ">=3.2.3" },
|
||||
|
@ -244,6 +245,7 @@ dev = [
|
|||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.26.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.0" },
|
||||
{ name = "pytest-docker", specifier = ">=3.2.3" },
|
||||
{ name = "pytest-logdog", specifier = ">=0.1.0" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
||||
{ name = "ruff", specifier = ">=0.11.3" },
|
||||
|
@ -1993,6 +1995,27 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1-modules"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
|
@ -2209,6 +2232,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-docker"
|
||||
version = "3.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/75/285187953062ebe38108e77a7919c75e157943fa3513371c88e27d3df7b2/pytest_docker-3.2.3.tar.gz", hash = "sha256:26a1c711d99ef01e86e7c9c007f69641552c1554df4fccb065b35581cca24206", size = 13452, upload-time = "2025-07-04T07:46:17.647Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/c7/e057e0d1de611ce1bbb26cccf07ddf56eb30a6f6a03aa512a09dac356e03/pytest_docker-3.2.3-py3-none-any.whl", hash = "sha256:f973c35e6f2b674c8fc87e8b3354b02c15866a21994c0841a338c240a05de1eb", size = 8585, upload-time = "2025-07-04T07:46:16.439Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-logdog"
|
||||
version = "0.1.0"
|
||||
|
@ -2245,6 +2281,16 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-ldap"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pyasn1-modules" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/8b/1eeb4025dc1d3ac2f16678f38dec9ebdde6271c74955b72db5ce7a4dbfbd/python-ldap-3.4.4.tar.gz", hash = "sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828", size = 377889, upload-time = "2023-11-17T21:14:16.32Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
|
|
Ładowanie…
Reference in New Issue