From f547da4cf16091cd7fe6850534b4aeb57fb771eb Mon Sep 17 00:00:00 2001 From: Julian Rother <julian@jrother.eu> Date: Tue, 23 Nov 2021 16:23:13 +0100 Subject: [PATCH] Fixed, updated and extended api documentation --- docs/api.rst | 180 +++++++++++++++++++++++++++---- ldapserver/dn.py | 149 ++++++++++++++++++++++--- ldapserver/exceptions.py | 70 ++++++++++++ ldapserver/ldap.py | 84 +++++++-------- ldapserver/objects.py | 89 ++++++++++++++- ldapserver/schema/__init__.py | 24 ++++- ldapserver/schema/definitions.py | 127 ++++++++++++++++++++-- ldapserver/schema/types.py | 169 +++++++++++++++++++++++++++-- ldapserver/server.py | 134 +++++++++++------------ 9 files changed, 855 insertions(+), 171 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 9b39ba7..6aeda77 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,26 +4,150 @@ API Reference Request Handler --------------- -.. autoclass:: ldapserver.BaseLDAPRequestHandler - :members: +.. autoclass:: ldapserver.LDAPRequestHandler + :members: + :exclude-members: do_bind_simple, do_bind_sasl -.. autoclass:: ldapserver.SimpleLDAPRequestHandler - :members: + .. autoattribute:: logger Distinguished Names ------------------- -.. automodule:: ldapserver.dn - :members: +.. autoclass:: ldapserver.DN + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.RDN + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.RDNAssertion + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.DNWithUID + :members: + :special-members: __str__ + +Objects +------- + +.. autoclass:: ldapserver.AttributeDict + :members: + +.. autoclass:: ldapserver.Object + :members: + +.. autoclass:: ldapserver.RootDSE + :members: + +.. autoclass:: ldapserver.SubschemaSubentry + :members: + +.. autodata:: ldapserver.WILDCARD_VALUE + :no-value: + +.. autoclass:: ldapserver.ObjectTemplate + :members: + +Schema +------ + +The :class:`schema.Schema` class and the schema-bound classes like :class:`schema.Syntax` provide a high-level interface to the low-level schema definition objects. + +Objects of the schema-bound classes reference other objects within their schema directly. +They are automatically instanciated when a :any:`schema.Schema` object is created based on the passed definition objects. + +.. autoclass:: ldapserver.schema.Schema + :members: + +.. autoclass:: ldapserver.schema.ObjectClass + :members: + +.. autoclass:: ldapserver.schema.AttributeType + :members: + +.. autoclass:: ldapserver.schema.EqualityMatchingRule + :members: + :inherited-members: + +.. autoclass:: ldapserver.schema.OrderingMatchingRule + :members: + :inherited-members: + +.. autoclass:: ldapserver.schema.SubstrMatchingRule + :members: + :inherited-members: + +.. autoclass:: ldapserver.schema.Syntax + :members: + +Low-level Schema Definitions +---------------------------- + +:class:`schema.AttributeTypeDefinition` and :class:`schema.ObjectClassDefinition` objects are nothing more than parsed representations of attribute type and object class definition strings found in standard documents such as RFC4519. +:class:`schema.MatchingRuleDefinition` and :class:`schema.SyntaxDefinition` also provide methods for encoding/decoding or matching. + +Instance attributes of definition objects are named after the definition string keywords converted to snake-case (e.g. `NAME` becomes `name`, `NO-USER-MODIFICATION` becomes `no_user_modification`). + +.. autoclass:: ldapserver.schema.SyntaxDefinition + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.schema.MatchingRuleDefinition + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.schema.MatchingRuleUseDefinition + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.schema.AttributeTypeUsage + :members: + +.. autoclass:: ldapserver.schema.AttributeTypeDefinition + :members: + :special-members: __str__ + +.. autoclass:: ldapserver.schema.ObjectClassKind + :members: + +.. autoclass:: ldapserver.schema.ObjectClassDefinition + :members: + :special-members: __str__ + +Built-in Schemas +---------------- + +.. autodata:: ldapserver.schema.RFC4512_SCHEMA + :no-value: +.. autodata:: ldapserver.schema.CORE_SCHEMA + :annotation: = RFC4512_SCHEMA -Directory Objects ------------------ +.. autodata:: ldapserver.schema.RFC4519_SCHEMA + :no-value: -.. autoclass:: ldapserver.directory.BaseDirectory - :members: +.. autodata:: ldapserver.schema.RFC4523_SCHEMA + :no-value: -.. autoclass:: ldapserver.directory.FilterMixin - :members: +.. autodata:: ldapserver.schema.RFC4524_SCHEMA + :no-value: +.. autodata:: ldapserver.schema.COSINE_SCHEMA + :annotation: = RFC4524_SCHEMA + +.. autodata:: ldapserver.schema.RFC3112_SCHEMA + :no-value: + +.. autodata:: ldapserver.schema.RFC2079_SCHEMA + :no-value: + +.. autodata:: ldapserver.schema.RFC2798_SCHEMA + :no-value: +.. autodata:: ldapserver.schema.INETORG_SCHMEA + :annotation: = RFC2798_SCHEMA + +.. autodata:: ldapserver.schema.RFC2307BIS_SCHEMA + :no-value: LDAP Protocol ------------- @@ -33,24 +157,44 @@ LDAP Protocol :undoc-members: .. autoclass:: ldapserver.ldap.Filter + :members: .. autoclass:: ldapserver.ldap.FilterAnd + :members: .. autoclass:: ldapserver.ldap.FilterOr + :members: .. autoclass:: ldapserver.ldap.FilterNot -.. autoclass:: ldapserver.ldap.FilterEqual + :members: .. autoclass:: ldapserver.ldap.FilterPresent + :members: +.. autoclass:: ldapserver.ldap.FilterEqual + :members: +.. autoclass:: ldapserver.ldap.FilterApproxMatch + :members: +.. autoclass:: ldapserver.ldap.FilterGreaterOrEqual + :members: +.. autoclass:: ldapserver.ldap.FilterLessOrEqual + :members: +.. autoclass:: ldapserver.ldap.FilterSubstrings + :members: +.. autoclass:: ldapserver.ldap.FilterExtensibleMatch + :members: -.. autoclass:: ldapserver.ldap.LDAPMessage +.. autoclass:: ldapserver.ldap.SearchResultEntry + :members: +.. autoclass:: ldapserver.ldap.PartialAttribute :members: LDAP Errors ----------- LDAP response messages carry a result code and an optional diagnostic message. -The subclasses of :any:`ldapserver.exceptions.LDAPError` represent the possible (non-success) result codes. +The subclasses of :any:`ldapserver.exceptions.LDAPError` represent the +possible (non-success) result codes. -Raising a :any:`ldapserver.exceptions.LDAPError` instance in a handler method of -:any:`ldapserver.BaseLDAPRequestHandler` cases the appropriate response message to be -sent with the corresponding result code and diagnostic message. +Raising an :any:`ldapserver.exceptions.LDAPError` instance in a handler method +of :any:`ldapserver.LDAPRequestHandler` aborts the request processing and sends +an appropriate response message with the corresponding result code and (if any) +the diagnostic message. .. autoexception:: ldapserver.exceptions.LDAPError .. autoexception:: ldapserver.exceptions.LDAPOperationsError diff --git a/ldapserver/dn.py b/ldapserver/dn.py index eb90cbc..b6ce3bf 100644 --- a/ldapserver/dn.py +++ b/ldapserver/dn.py @@ -22,7 +22,8 @@ from . import exceptions __all__ = ['DN', 'RDN', 'RDNAssertion', 'DNWithUID'] class DN(tuple): - '''Distinguished Name consiting of zero ore more `RDN` objects''' + '''Distinguished Name consisting of zero ore more :class:`RDN` objects''' + #: schema: typing.Any def __new__(cls, schema, *args, **kwargs): @@ -45,6 +46,15 @@ class DN(tuple): @classmethod def from_str(cls, schema, expr): + '''Parse string representation of a DN according to RFC 4514 + + :param schema: Schema for the DN + :type schema: schema.Schema + :param expr: DN string representation + :type expr: str + :raises ValueError: if expr is invalid + :returns: Parsed DN + :rtype: DN''' escaped = False rdns = [] token = '' @@ -64,10 +74,14 @@ class DN(tuple): return cls(schema, *rdns) def __str__(self): + '''Return string representation of DN according to RFC 4514 + + :returns: Representation of self + :rtype: str''' return ','.join(map(str, self)) def __repr__(self): - return '<ldapserver.dn.DN %s>'%str(self) + return '<ldapserver.DN %s>'%str(self) def __eq__(self, obj): return type(self) is type(obj) and super().__eq__(obj) @@ -97,38 +111,85 @@ class DN(tuple): return self[:-minlen or None], value[:-minlen or None] def is_direct_child_of(self, base): + '''Return whether self is a direct child of base + + :param base: parent DN + :type base: DN + :rtype: bool + + Example: + + >>> schema = schema.RFC4519_SCHEMA + >>> dn1 = DN(schema, 'uid=jsmith,dc=example,dc=net') + >>> dn2 = DN(schema, 'dc=example,dc=net') + >>> dn3 = DN(schema, 'dc=net') + >>> dn1.is_direct_child_of(dn2) + True + >>> dn1.is_direct_child_of(dn1) or dn1.is_direct_child_of(dn3) + False + + ''' rchild, rbase = self.__strip_common_suffix(DN(self.schema, base)) return not rbase and len(rchild) == 1 def in_subtree_of(self, base): + '''Return whether self is in the subtree of base + + :param base: parent DN + :type base: DN + :rtype: bool + + Example: + + >>> schema = schema.RFC4519_SCHEMA + >>> dn1 = DN(schema, 'uid=jsmith,dc=example,dc=net') + >>> dn2 = DN(schema, 'dc=example,dc=net') + >>> dn3 = DN(schema, 'dc=net') + >>> dn1.in_subtree_of(dn1) and dn1.in_subtree_of(dn2) and dn1.in_subtree_of(dn3) + True + >>> dn2.in_subtree_of(dn1) + False + + ''' rchild, rbase = self.__strip_common_suffix(DN(self.schema, base)) # pylint: disable=unused-variable return not rbase @property def object_attribute(self): + '''Attribute name of the first RDN. None if there are no RDNs or if the + first RDN consists of more than one assertion.''' if len(self) == 0: return None return self[0].attribute # pylint: disable=no-member @property def object_attribute_type(self): + ''':any:`schema.AttributeType` of the first RDN. None if there are no RDNs + or if the first RDN consists of more than one assertion.''' if len(self) == 0: return None return self[0].attribute_type # pylint: disable=no-member @property def object_value(self): + '''Attribute value of the first RDN. None if there are no RDNs or if the + first RDN consists of more than one assertion. Type of value depends on + the syntax of the attribute type.''' if len(self) == 0: return None return self[0].value # pylint: disable=no-member - @property - def object_value_normalized(self): - if len(self) == 0: - return None - return self[0].value_normalized # pylint: disable=no-member - class DNWithUID(DN): + '''Distinguished Name with an optional bit string + + Used to represent values of the "Name and Optional UID" syntax (see RFC4517) + This syntax is used for e.g. the "uniqueMember" attribute type (RFC4519). + + If created without the optional bit string (uid) part, a regular DN object is + returned.''' + #: + schema: typing.Any + # pylint: disable=arguments-differ,no-member def __new__(cls, schema, dn, uid=None): if not uid: @@ -141,24 +202,42 @@ class DNWithUID(DN): @classmethod def from_str(cls, schema, expr): + '''Parse string representation + + :param schema: Schema + :type schema: schema.Schema + :param expr: DN string representation with optional ``#``-separated bit + string part (e.g. ``0b1010``) + :type expr: str + :raises ValueError: if expr is invalid + :returns: :any:`DNWithUID` if expr includes the optional bit string part, + :any:`DN` otherwise + :rtype: DN or DNWithUID + ''' dn_part, uid_part = (expr.rsplit('#', 1) + [''])[:2] return cls(schema, DN.from_str(schema, dn_part), uid_part or None) def __str__(self): + '''Return string representation + + :returns: Representation of self + :rtype: str''' return super().__str__() + '#' + self.uid def __repr__(self): - return '<ldapserver.dn.DNWithUID %s>'%str(self) + return '<ldapserver.DNWithUID %s>'%str(self) def __eq__(self, obj): return type(self) is type(obj) and super().__eq__(obj) and self.uid == obj.uid @property def dn(self): + '''Return DN part as a regular :any:`DN` object''' return DN(self.schema, *self) class RDN(tuple): - '''Relative Distinguished Name consisting of one or more `RDNAssertion` objects''' + '''Relative Distinguished Name consisting of one or more :class:`RDNAssertion` objects''' + #: schema: typing.Any def __new__(cls, schema, *assertions, **kwargs): @@ -181,6 +260,15 @@ class RDN(tuple): @classmethod def from_str(cls, schema, expr): + '''Parse string representation of an RDN according to RFC 4514 + + :param schema: Schema for the RDN + :type schema: schema.Schema + :param expr: RDN string representation + :type expr: str + :raises ValueError: if expr is invalid + :returns: Parsed RDN + :rtype: RDN''' escaped = False assertions = [] token = '' @@ -200,10 +288,20 @@ class RDN(tuple): return cls(schema, *assertions) def __str__(self): + '''Return string representation of RDN according to RFC 4514 + + :returns: Representation of self + :rtype: str + + Example: + + >>> str(RDN(ou='Sales', cn='J. Smith')) + 'ou=Sales+cn=J. Smith' + ''' return '+'.join(map(str, self)) def __repr__(self): - return '<ldapserver.dn.RDN %s>'%str(self) + return '<ldapserver.RDN %s>'%str(self) def __eq__(self, obj): return type(self) is type(obj) and set(self) == set(obj) @@ -221,18 +319,25 @@ class RDN(tuple): @property def attribute(self): + '''Attribute name of the contained assertion. None if the RDN consists of + more than one assertion.''' if len(self) != 1: return None return self[0].attribute @property def attribute_type(self): + '''Attribute type of the contained assertion. None if the RDN consists of + more than one assertion.''' if len(self) != 1: return None return self[0].attribute_type @property def value(self): + '''Attribute value of the contained assertion. None if the RDN consists of + more than one assertion. Type of value depends on the syntax of the + attribute type.''' if len(self) != 1: return None return self[0].value @@ -276,11 +381,14 @@ HEXDIGITS = ( ) class RDNAssertion: - '''A single attribute value assertion''' - __slots__ = ['attribute', 'attribute_type', 'value'] + __slots__ = ['attribute', 'attribute_type', 'value', 'schema'] + #: Attribute name attribute: str + #: :class:`schema.AttributeType` attribute_type: typing.Any + #: Attribute value (type depends on the syntax of the attribute type) value: typing.Any + #: schema: typing.Any def __init__(self, schema, attribute, value): @@ -326,6 +434,15 @@ class RDNAssertion: @classmethod def from_str(cls, schema, expr): + '''Parse string representation of an RDN assertion according to RFC 4514 + + :param schema: Schema for the RDN assertion + :type schema: schema.Schema + :param expr: RDN assertion string representation + :type expr: str + :raises ValueError: if expr is invalid + :returns: Parsed assertion + :rtype: RDNAssertion''' attribute, escaped_value = expr.split('=', 1) if escaped_value.startswith('#'): # The "#..." form is used for unknown attribute types and those without @@ -363,6 +480,10 @@ class RDNAssertion: return cls(schema, attribute, value) def __str__(self): + '''Return string representation of the RDN assertion according to RFC 4514 + + :returns: Representation of self + :rtype: str''' encoded_value = self.attribute_type.syntax.encode(self.value) escaped_value = '' for index in range(len(encoded_value)): @@ -383,7 +504,7 @@ class RDNAssertion: return hash(self.attribute_type.oid) def __repr__(self): - return '<ldapserver.dn.RDNAssertion %s>'%str(self) + return '<ldapserver.RDNAssertion %s>'%str(self) def __eq__(self, obj): return type(self) is type(obj) and self.attribute_type is obj.attribute_type and \ diff --git a/ldapserver/exceptions.py b/ldapserver/exceptions.py index 109e310..b10a6a6 100644 --- a/ldapserver/exceptions.py +++ b/ldapserver/exceptions.py @@ -14,15 +14,22 @@ class LDAPError(Exception): # RESULT_CODE = LDAPResultCode.success class LDAPOperationsError(LDAPError): + '''Indicates that the operation is not properly sequenced with relation to + other operations (of same or different type). (RFC 4511)''' RESULT_CODE = LDAPResultCode.operationsError class LDAPProtocolError(LDAPError): + '''Indicates the server received data that is not well-formed. (RFC 4511)''' RESULT_CODE = LDAPResultCode.protocolError class LDAPTimeLimitExceeded(LDAPError): + '''Indicates that the time limit specified by the client was + exceeded before the operation could be completed. (RFC 4511)''' RESULT_CODE = LDAPResultCode.timeLimitExceeded class LDAPSizeLimitExceeded(LDAPError): + '''Indicates that the size limit specified by the client was + exceeded before the operation could be completed. (RFC 4511)''' RESULT_CODE = LDAPResultCode.sizeLimitExceeded #class LDAPCompareFalse(LDAPError): @@ -32,97 +39,160 @@ class LDAPSizeLimitExceeded(LDAPError): # RESULT_CODE = LDAPResultCode.compareTrue class LDAPAuthMethodNotSupported(LDAPError): + '''Indicates that the authentication method or mechanism is not + supported. (RFC 4511)''' RESULT_CODE = LDAPResultCode.authMethodNotSupported class LDAPStrongerAuthRequired(LDAPError): + '''Indicates the server requires strong(er) authentication in + order to complete the operation. (RFC 4511)''' RESULT_CODE = LDAPResultCode.strongerAuthRequired #class LDAPReferral(LDAPError): # RESULT_CODE = LDAPResultCode.referral class LDAPAdminLimitExceeded(LDAPError): + '''Indicates that an administrative limit has been exceeded. (RFC 4511)''' RESULT_CODE = LDAPResultCode.adminLimitExceeded class LDAPUnavailableCriticalExtension(LDAPError): + '''Indicates a critical control is unrecognized. (RFC 4511)''' RESULT_CODE = LDAPResultCode.unavailableCriticalExtension class LDAPConfidentialityRequired(LDAPError): + '''Indicates that data confidentiality protections are required. (RFC 4511)''' RESULT_CODE = LDAPResultCode.confidentialityRequired #class LDAPSaslBindInProgress(LDAPError): # RESULT_CODE = LDAPResultCode.saslBindInProgress class LDAPNoSuchAttribute(LDAPError): + '''Indicates that the named entry does not contain the specified + attribute or attribute value. (RFC 4511)''' RESULT_CODE = LDAPResultCode.noSuchAttribute class LDAPUndefinedAttributeType(LDAPError): + '''Indicates that a request field contains an unrecognized + attribute description. (RFC 4511)''' RESULT_CODE = LDAPResultCode.undefinedAttributeType class LDAPInappropriateMatching(LDAPError): + '''Indicates that an attempt was made (e.g., in an assertion) to + use a matching rule not defined for the attribute type + concerned. (RFC 4511)''' RESULT_CODE = LDAPResultCode.inappropriateMatching class LDAPConstraintViolation(LDAPError): + '''Indicates that the client supplied an attribute value that + does not conform to the constraints placed upon it by the + data model. (RFC 4511)''' RESULT_CODE = LDAPResultCode.constraintViolation class LDAPAttributeOrValueExists(LDAPError): + '''Indicates that the client supplied an attribute or value to + be added to an entry, but the attribute or value already + exists. (RFC 4511)''' RESULT_CODE = LDAPResultCode.attributeOrValueExists class LDAPInvalidAttributeSyntax(LDAPError): + ''' Indicates that a purported attribute value does not conform + to the syntax of the attribute. (RFC 4511)''' RESULT_CODE = LDAPResultCode.invalidAttributeSyntax class LDAPNoSuchObject(LDAPError): + '''Indicates that the object does not exist in the DIT. (RFC 4511)''' RESULT_CODE = LDAPResultCode.noSuchObject class LDAPAliasProblem(LDAPError): + '''Indicates that an alias problem has occurred. For example, + the code may used to indicate an alias has been dereferenced + that names no object. (RFC 4511)''' RESULT_CODE = LDAPResultCode.aliasProblem class LDAPInvalidDNSyntax(LDAPError): + '''Indicates that an LDAPDN or RelativeLDAPDN field (e.g., search + base, target entry, ModifyDN newrdn, etc.) of a request does + not conform to the required syntax or contains attribute + values that do not conform to the syntax of the attribute's + type. (RFC 4511)''' RESULT_CODE = LDAPResultCode.invalidDNSyntax class LDAPAliasDereferencingProblem(LDAPError): + '''Indicates that a problem occurred while dereferencing an + alias. Typically, an alias was encountered in a situation + where it was not allowed or where access was denied. (RFC 4511)''' RESULT_CODE = LDAPResultCode.aliasDereferencingProblem class LDAPInappropriateAuthentication(LDAPError): + '''Indicates the server requires the client that had attempted + to bind anonymously or without supplying credentials to + provide some form of credentials. (RFC 4511)''' RESULT_CODE = LDAPResultCode.inappropriateAuthentication class LDAPInvalidCredentials(LDAPError): + '''Indicates that the provided credentials (e.g., the user's name + and password) are invalid. (RFC 4511)''' RESULT_CODE = LDAPResultCode.invalidCredentials class LDAPInsufficientAccessRights(LDAPError): + '''Indicates that the client does not have sufficient access + rights to perform the operation. (RFC 4511)''' RESULT_CODE = LDAPResultCode.insufficientAccessRights class LDAPBusy(LDAPError): + '''Indicates that the server is too busy to service the + operation. (RFC 4511)''' RESULT_CODE = LDAPResultCode.busy class LDAPUnavailable(LDAPError): + '''Indicates that the server is shutting down or a subsystem + necessary to complete the operation is offline. (RFC 4511)''' RESULT_CODE = LDAPResultCode.unavailable class LDAPUnwillingToPerform(LDAPError): + '''Indicates that the server is unwilling to perform the + operation. (RFC 4511)''' RESULT_CODE = LDAPResultCode.unwillingToPerform class LDAPLoopDetect(LDAPError): + '''Indicates that the server has detected an internal loop (e.g., + while dereferencing aliases or chaining an operation). (RFC 4511)''' RESULT_CODE = LDAPResultCode.loopDetect class LDAPNamingViolation(LDAPError): + '''Indicates that the entry's name violates naming restrictions. (RFC 4511)''' RESULT_CODE = LDAPResultCode.namingViolation class LDAPObjectClassViolation(LDAPError): + '''Indicates that the entry violates object class restrictions. (RFC 4511)''' RESULT_CODE = LDAPResultCode.objectClassViolation class LDAPNotAllowedOnNonLeaf(LDAPError): + '''Indicates that the operation is inappropriately acting upon a + non-leaf entry. (RFC 4511)''' RESULT_CODE = LDAPResultCode.notAllowedOnNonLeaf class LDAPNotAllowedOnRDN(LDAPError): + '''Indicates that the operation is inappropriately attempting to + remove a value that forms the entry's relative distinguished + name. (RFC 4511)''' RESULT_CODE = LDAPResultCode.notAllowedOnRDN class LDAPEntryAlreadyExists(LDAPError): + '''Indicates that the request cannot be fulfilled (added, moved, + or renamed) as the target entry already exists. (RFC 4511)''' RESULT_CODE = LDAPResultCode.entryAlreadyExists class LDAPObjectClassModsProhibited(LDAPError): + '''Indicates that an attempt to modify the object class(es) of + an entry's 'objectClass' attribute is prohibited. (RFC 4511)''' RESULT_CODE = LDAPResultCode.objectClassModsProhibited class LDAPAffectsMultipleDSAs(LDAPError): + '''Indicates that the operation cannot be performed as it would + affect multiple servers (DSAs). (RFC 4511)''' RESULT_CODE = LDAPResultCode.affectsMultipleDSAs class LDAPOther(LDAPError): + '''Indicates the server has encountered an internal error. (RFC 4511)''' RESULT_CODE = LDAPResultCode.other diff --git a/ldapserver/ldap.py b/ldapserver/ldap.py index 8fdb990..980c3ac 100644 --- a/ldapserver/ldap.py +++ b/ldapserver/ldap.py @@ -39,26 +39,23 @@ def escape_filter_assertionvalue(value): return bytes(res).decode() class Filter(asn1.Choice, ABC): - '''Base class for filters in SEARCH operations''' + '''Base class for filters in SEARCH operations + + All subclasses implement ``__str__`` according to RFC4515 "String + Representation of Search Filters".''' @abstractmethod def __str__(self): raise NotImplementedError() class FilterAnd(asn1.Wrapper, Filter): - '''AND conjunction of multiple filters ``(&filters...)`` - - .. py:attribute:: filters - :type: list - :value: [] - - List of :any:`Filter` objects - ''' + '''AND conjunction of multiple filters ``(&filters...)``''' BER_TAG = (2, True, 0) WRAPPED_ATTRIBUTE = 'filters' WRAPPED_TYPE = asn1.Set WRAPPED_CLSATTRS = {'SET_TYPE': Filter} + #: filters: typing.List[Filter] def __init__(self, filters=None): @@ -68,19 +65,13 @@ class FilterAnd(asn1.Wrapper, Filter): return '(&%s)'%(''.join([str(subfilter) for subfilter in self.filters])) class FilterOr(asn1.Wrapper, Filter): - '''OR conjunction of multiple filters ``(|filters...)`` - - .. py:attribute:: filters - :type: list - :value: [] - - List of :any:`Filter` objects - ''' + '''OR conjunction of multiple filters ``(|filters...)``''' BER_TAG = (2, True, 1) WRAPPED_ATTRIBUTE = 'filters' WRAPPED_TYPE = asn1.Set WRAPPED_CLSATTRS = {'SET_TYPE': Filter} + #: filters: typing.List[Filter] def __init__(self, filters=None): @@ -90,17 +81,14 @@ class FilterOr(asn1.Wrapper, Filter): return '(|%s)'%(''.join([str(subfilter) for subfilter in self.filters])) class FilterNot(asn1.Sequence, Filter): - '''Negation of a filter ``(!filter)`` - - .. py:attribute:: filter - :type: Filter - ''' + '''Negation of a filter ``(!filter)``''' BER_TAG = (2, True, 2) SEQUENCE_FIELDS = [ (Filter, 'filter', None, False) ] + #: filter: Filter # pylint: disable=redefined-builtin @@ -111,20 +99,16 @@ class FilterNot(asn1.Sequence, Filter): return '(!%s)'%str(self.filter) class FilterEqual(asn1.Sequence, Filter): - '''Attribute equal filter ``(attribute=value)`` - - .. py:attribute:: attribute - :type: str - .. py:attribute:: value - :type: bytes - ''' + '''Attribute equal filter ``(attribute=value)``''' BER_TAG = (2, True, 3) SEQUENCE_FIELDS = [ (LDAPString, 'attribute', None, False), (asn1.OctetString, 'value', None, False) ] + #: attribute: str + #: value: bytes def __init__(self, attribute=None, value=None): @@ -161,29 +145,20 @@ class Substrings(asn1.SequenceOf): SET_TYPE = Substring class FilterSubstrings(asn1.Sequence, Filter): - '''Attribute substrings filter ``(attribute=initial*any*final)`` - - .. py:attribute:: attribute - :type: str - .. py:attribute:: inital - :type: bytes - .. py:attribute:: any - :type: bytes - ''' + '''Attribute substrings filter ``(attribute=initial*any1*any2*final)``''' BER_TAG = (2, True, 4) SEQUENCE_FIELDS = [ (LDAPString, 'attribute', None, False), (Substrings, 'substrings', lambda: [], False) ] + #: attribute: str substrings: typing.List[Substring] - def __init__(self, attribute=None, substrings=None): - super().__init__(attribute=attribute, substrings=substrings) - @property def initial_substring(self): + '''Initial substring (:any:`bytes`). None if there is no initial substring or if the filter is invalid.''' results = [substring.value for substring in self.substrings if isinstance(substring, InitialSubstring)] if len(results) != 1: return None @@ -191,10 +166,12 @@ class FilterSubstrings(asn1.Sequence, Filter): @property def any_substrings(self): + '''List of "any" substrings (list of :any:`bytes`, may be empty)''' return [substring.value for substring in self.substrings if isinstance(substring, AnySubstring)] @property def final_substring(self): + '''Final substring (:any:`bytes`). None if there is no final substring or if the filter is invalid.''' results = [substring.value for substring in self.substrings if isinstance(substring, FinalSubstring)] if len(results) != 1: return None @@ -206,6 +183,7 @@ class FilterSubstrings(asn1.Sequence, Filter): return f'({self.attribute}={value})' class FilterGreaterOrEqual(asn1.Sequence, Filter): + '''Attribute greater or equal filter ``(attribute>=value)``''' BER_TAG = (2, True, 5) SEQUENCE_FIELDS = [ (LDAPString, 'attribute', None, False), @@ -222,13 +200,16 @@ class FilterGreaterOrEqual(asn1.Sequence, Filter): return '(%s>=%s)'%(self.attribute, escape_filter_assertionvalue(self.value)) class FilterLessOrEqual(asn1.Sequence, Filter): + '''Attribute less or equal filter ``(attribute<=value)``''' BER_TAG = (2, True, 6) SEQUENCE_FIELDS = [ (LDAPString, 'attribute', None, False), (asn1.OctetString, 'value', None, False) ] + #: attribute: str + #: value: bytes def __init__(self, attribute=None, value=None): @@ -238,16 +219,13 @@ class FilterLessOrEqual(asn1.Sequence, Filter): return '(%s<=%s)'%(self.attribute, escape_filter_assertionvalue(self.value)) class FilterPresent(asn1.Wrapper, Filter): - '''Attribute present filter ``(attribute=*)`` - - .. py:attribute:: attribute - :type: str - ''' + '''Attribute present filter ``(attribute=*)``''' BER_TAG = (2, False, 7) WRAPPED_ATTRIBUTE = 'attribute' WRAPPED_TYPE = LDAPString WRAPPED_DEFAULT = None + #: attribute: str def __init__(self, attribute=None): @@ -257,13 +235,16 @@ class FilterPresent(asn1.Wrapper, Filter): return '(%s=*)'%(self.attribute) class FilterApproxMatch(asn1.Sequence, Filter): + '''Attribute approximately equal filter ``(attribute~=value)``''' BER_TAG = (2, True, 8) SEQUENCE_FIELDS = [ (LDAPString, 'attribute', None, False), (asn1.OctetString, 'value', None, False) ] + #: attribute: str + #: value: bytes def __init__(self, attribute=None, value=None): @@ -273,6 +254,7 @@ class FilterApproxMatch(asn1.Sequence, Filter): return '(%s~=%s)'%(self.attribute, escape_filter_assertionvalue(self.value)) class FilterExtensibleMatch(asn1.Sequence, Filter): + '''Extensible match filter ``(attribute:caseExactMatch:=value)``''' BER_TAG = (2, True, 9) SEQUENCE_FIELDS = [ (asn1.retag(LDAPString, (2, False, 1)), 'matchingRule', None, True), @@ -281,9 +263,15 @@ class FilterExtensibleMatch(asn1.Sequence, Filter): (asn1.retag(asn1.Boolean, (2, False, 4)), 'dnAttributes', None, True), ] + #: Matching rule OID or short descriptive name (optional, str or None) matchingRule: str + #: Attribute type OID or short descriptive name (optional, str or None) type: str + #: Assertion value (bytes, encoded with LDAP-specific encoding according to matching rule syntax) matchValue: bytes + #: Apply matching to all RDN assertions in addition to attribute values + #: (e.g. ``(dc:dn:=example)`` matches ``cn=test,dc=example,dc=com`` even + #: if it has no ``dc`` attribute, because ``dc=example`` is part of its DN) dnAttributes: bool def __str__(self): @@ -412,7 +400,9 @@ class PartialAttribute(asn1.Sequence): (AttributeValueSet, 'vals', lambda: [], False), ] + #: type: str + #: vals: typing.List[bytes] class PartialAttributeList(asn1.SequenceOf): @@ -496,7 +486,9 @@ class SearchResultEntry(asn1.Sequence, ProtocolOp): (PartialAttributeList, 'attributes', lambda: [], False), ] + #: objectName: str + #: attributes: typing.List[PartialAttribute] class SearchResultDone(LDAPResult, ProtocolOp): diff --git a/ldapserver/objects.py b/ldapserver/objects.py index c568470..a8c0a55 100644 --- a/ldapserver/objects.py +++ b/ldapserver/objects.py @@ -47,6 +47,7 @@ class AttributeDict(collections.abc.MutableMapping): conform to the attribute's syntax.''' def __init__(self, schema, **attributes): self.__attributes = {} + #: self.schema = schema for key, values in attributes.items(): self[key] = values @@ -112,6 +113,7 @@ class FilterResult(enum.Enum): class Object(AttributeDict): def __init__(self, schema, dn, **attributes): super().__init__(schema, **attributes) + #: Objects distinguished name (:class:`DN`) self.dn = DN(schema, dn) def __search_match_dn(self, basedn, scope): @@ -218,6 +220,25 @@ class Object(AttributeDict): self.__search_match_filter(filter_obj) == FilterResult.TRUE def search(self, base_obj, scope, filter_obj, attributes, types_only): + '''Return SEARCH result for the object if it matches the operation + parameters + + :param base_obj: DN of the base object + :type base_obj: str + :param scope: Scope of base_obj + :type scope: ldap.SearchScope + :param filter_obj: Search filter + :type filter_obj: ldap.Filter + :param attributes: Requested attributes + :type attributes: list of str + :param types_only: Omit values in :class:`ldap.PartialAttribute` + :type types_only: bool + :returns: SEARCH result for the object if it matches the operation + parameters, None otherwise + :rtype: ldap.SearchResultEntry or None + + Conforms to RFC4511 and RFC4526 (empty AND/OR filters are supported and + treated as absolute TRUE/FALSE).''' if not self.match_search(base_obj, scope, filter_obj): return None selected_attributes = set() @@ -238,6 +259,22 @@ class Object(AttributeDict): return ldap.SearchResultEntry(str(self.dn), partial_attributes) def compare(self, dn, attribute, value): + '''Return the result of the COMPARE operation applied to the object + + :param dn: DN of the object to be compared + :type dn: str + :param attribute: Attribute OID or short descriptive name + :type attribute: str + :param value: Assertion value + :type value: bytes + :raises exceptions.LDAPNoSuchObject: if dn does not refer to the object + :raises exceptions.LDAPError: if operation results to anything other than + TRUE/FALSE + :return: True/False if COMPARE operation evaluates to TRUE/FALSE + :rtype: bool + + Evaluation is essentially applying the EQUALITY matching rule of attribute + on the values of attribute with the assertion value.''' try: dn = DN.from_str(self.schema, dn) except ValueError as exc: @@ -251,8 +288,8 @@ class Object(AttributeDict): return attribute_type.match_equal(self.get(attribute_type, subtypes=True), value) class RootDSE(Object): - def __init__(self, schema, *args, **kwargs): - super().__init__(schema, DN(schema), *args, **kwargs) + def __init__(self, schema, **attributes): + super().__init__(schema, DN(schema), **attributes) self.setdefault('objectClass', ['top']) def match_search(self, base_obj, scope, filter_obj): @@ -263,6 +300,7 @@ class RootDSE(Object): class WildcardValue: pass +#: Special wildcard value for :class:`ObjectTemplate` WILDCARD_VALUE = WildcardValue() class TemplateFilterResult(enum.Enum): @@ -429,18 +467,54 @@ class ObjectTemplate(AttributeDict): return AttributeDict(self.schema) def match_search(self, base_obj, scope, filter_obj): - '''Return whether objects based on this template might match the search parameters''' + '''Return whether objects based on this template might match the search parameters + + :param base_obj: DN of the base object + :type base_obj: str + :param scope: Scope of base_obj + :type scope: ldap.SearchScope + :param filter_obj: Search filter + :type filter_obj: ldap.Filter + :returns: True if objects based on this template might match the SEARCH + parameters or False if they do not match + :rtype: bool''' return self.__search_match_dn(DN.from_str(self.schema, base_obj), scope) and \ self.__search_match_filter(filter_obj) in (TemplateFilterResult.TRUE, TemplateFilterResult.MAYBE_TRUE) def extract_search_constraints(self, base_obj, scope, filter_obj): + '''Return approximate value constraints for objects that match SEARCH parameters + + :returns: :class:`AttributeDict` with values that any object must have + to match potentially match SEARCH parameters + :rtype: AttributeDict + + Example: + + >>> subschema = SubschemaSubentry(schema.RFC4519_SCHEMA, 'cn=Subschema') + >>> template = subschema.ObjectTemplate('ou=users,dc=example,dc=com', 'cn', objectclass=['person', 'top'], sn=[WILDCARD_VALUE]) + >>> template.extract_search_constraints('cn=test,ou=users,dc=example,dc=com', ldap.SearchScope.baseObject, ldap.FilterEqual('sn', b'foobar')) + subschema.AttributeDict(cn=['test'], sn=['foobar']) + + Note that the results are an approximation (i.e. some constrains may be + missing) and the quality may improve over time. Also note that every object + that matches the SEARCH parameters also matches the constrains, but not + vice versa.''' constraints = self.__extract_filter_constraints(filter_obj) for key, values in self.__extract_dn_constraints(DN.from_str(self.schema, base_obj), scope).items(): constraints[key] += values return constraints def create_object(self, rdn_value, **attributes): + '''Instanciate :class:`Object` based on template + + :param rdn_value: RDN value for DN construction + :type rdn_value: any + :rtype: Object + + Template attributes set to :any:`WILDCARD_VALUE` are stripped. Only + template attributes set to :any:`WILDCARD_VALUE` may be overwritten + with `attributes`.''' obj = Object(self.schema, DN(self.schema, self.parent_dn, **{self.rdn_attribute: rdn_value})) for key, values in attributes.items(): if WILDCARD_VALUE not in self[key]: @@ -452,7 +526,7 @@ class ObjectTemplate(AttributeDict): return obj class SubschemaSubentry(Object): - '''Special :any:`Object` providing information on a Schema''' + '''Special :class:`Object` providing information on a Schema''' def __init__(self, schema, dn, **attributes): super().__init__(schema, dn, **attributes) self['subschemaSubentry'] = [self.dn] @@ -464,9 +538,13 @@ class SubschemaSubentry(Object): self['attributeTypes'] = schema.attribute_type_definitions self['matchingRuleUse'] = schema.matching_rule_use_definitions # pylint: disable=invalid-name + #: Shorthand for :class:`AttributeDict` self.AttributeDict = lambda **attributes: AttributeDict(schema, **attributes) + #: Shorthand for :class:`Object` self.Object = lambda *args, **attributes: Object(schema, *args, subschemaSubentry=[self.dn], **attributes) + #: Shorthand for :class:`RootDSE` self.RootDSE = lambda **attributes: RootDSE(schema, subschemaSubentry=[self.dn], **attributes) + #: Shorthand for :class:`ObjectTemplate` self.ObjectTemplate = lambda *args, **kwargs: ObjectTemplate(schema, *args, subschemaSubentry=[self.dn], **kwargs) class Wrapper: def __init__(self, cls, schema): @@ -479,8 +557,11 @@ class SubschemaSubentry(Object): def from_str(self, *args, **kwargs): return self.cls.from_str(self.schema, *args, **kwargs) + #: Shorthand for :class:`DN` self.DN = Wrapper(DN, schema) + #: Shorthand for :class:`RDN` self.RDN = Wrapper(RDN, schema) + #: Shorthand for :class:`RDNAssertion` self.RDNAssertion = Wrapper(RDNAssertion, schema) def match_search(self, base_obj, scope, filter_obj): diff --git a/ldapserver/schema/__init__.py b/ldapserver/schema/__init__.py index 8dda82c..908e128 100644 --- a/ldapserver/schema/__init__.py +++ b/ldapserver/schema/__init__.py @@ -1,4 +1,4 @@ -from .types import Schema +from .types import * from .definitions import * from . import syntaxes, matching_rules @@ -34,7 +34,10 @@ RFC4512_OBJECT_CLASSES = [ "( 1.3.6.1.4.1.1466.101.120.111 NAME 'extensibleObject' SUP top AUXILIARY )", "( 2.5.20.1 NAME 'subschema' AUXILIARY MAY ( dITStructureRules $ nameForms $ ditContentRules $ objectClasses $ attributeTypes $ matchingRules $ matchingRuleUse ) )", ] -CORE_SCHEMA = RFC4512_SCHEMA = Schema(syntax_definitions=syntaxes.ALL, matching_rule_definitions=matching_rules.ALL, attribute_type_definitions=RFC4512_ATTRIBUTE_TYPES, object_class_definitions=RFC4512_OBJECT_CLASSES) +#: +RFC4512_SCHEMA = Schema(syntax_definitions=syntaxes.ALL, matching_rule_definitions=matching_rules.ALL, attribute_type_definitions=RFC4512_ATTRIBUTE_TYPES, object_class_definitions=RFC4512_OBJECT_CLASSES) +#: +CORE_SCHEMA = RFC4512_SCHEMA RFC4519_ATTRIBUTE_TYPES = [ "( 2.5.4.15 NAME 'businessCategory' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )", @@ -97,6 +100,7 @@ RFC4519_OBJECT_CLASSES = [ "( 2.5.6.10 NAME 'residentialPerson' SUP person STRUCTURAL MUST l MAY ( businessCategory $ x121Address $ registeredAddress $ destinationIndicator $ preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $ telephoneNumber $ internationalISDNNumber $ facsimileTelephoneNumber $ preferredDeliveryMethod $ street $ postOfficeBox $ postalCode $ postalAddress $ physicalDeliveryOfficeName $ st $ l ) )", "( 1.3.6.1.1.3.1 NAME 'uidObject' SUP top AUXILIARY MUST uid )", ] +#: RFC4519_SCHEMA = CORE_SCHEMA.extend(attribute_type_definitions=RFC4519_ATTRIBUTE_TYPES, object_class_definitions=RFC4519_OBJECT_CLASSES) RFC4523_ATTRIBUTE_TYPES = [ @@ -118,6 +122,7 @@ RFC4523_OBJECT_CLASSES = [ "( 2.5.6.16 NAME 'certificationAuthority' DESC 'X.509 certificate authority' SUP top AUXILIARY MUST ( authorityRevocationList $ certificateRevocationList $ cACertificate ) MAY crossCertificatePair )", "( 2.5.6.16.2 NAME 'certificationAuthority-V2' DESC 'X.509 certificate authority, version 2' SUP certificationAuthority AUXILIARY MAY deltaRevocationList )", ] +#: RFC4523_SCHEMA = RFC4519_SCHEMA.extend(attribute_type_definitions=RFC4523_ATTRIBUTE_TYPES, object_class_definitions=RFC4523_OBJECT_CLASSES) RFC4524_ATTRIBUTE_TYPES = [ @@ -158,7 +163,10 @@ RFC4524_OBJECT_CLASSES = [ "( 0.9.2342.19200300.100.4.7 NAME 'room' SUP top STRUCTURAL MUST cn MAY ( roomNumber $ description $ seeAlso $ telephoneNumber ) )", "( 0.9.2342.19200300.100.4.19 NAME 'simpleSecurityObject' SUP top AUXILIARY MUST userPassword )", ] -COSINE_SCHEMA = RFC4524_SCHEMA = RFC4519_SCHEMA.extend(attribute_type_definitions=RFC4524_ATTRIBUTE_TYPES, object_class_definitions=RFC4524_OBJECT_CLASSES) +#: +RFC4524_SCHEMA = RFC4519_SCHEMA.extend(attribute_type_definitions=RFC4524_ATTRIBUTE_TYPES, object_class_definitions=RFC4524_OBJECT_CLASSES) +#: +COSINE_SCHEMA = RFC4524_SCHEMA RFC3112_ATTRIBUTE_TYPES = [ "( 1.3.6.1.4.1.4203.1.3.3 NAME 'supportedAuthPasswordSchemes' DESC 'supported password storage schemes' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{32} USAGE dSAOperation )", @@ -167,6 +175,7 @@ RFC3112_ATTRIBUTE_TYPES = [ RFC3112_OBJECT_CLASSES = [ "( 1.3.6.1.4.1.4203.1.4.7 NAME 'authPasswordObject' DESC 'authentication password mix in class' AUXILIARY MAY authPassword )", ] +#: RFC3112_SCHEMA = CORE_SCHEMA.extend(attribute_type_definitions=RFC3112_ATTRIBUTE_TYPES, object_class_definitions=RFC3112_OBJECT_CLASSES) RFC2079_ATTRIBUTE_TYPES = [ @@ -175,6 +184,8 @@ RFC2079_ATTRIBUTE_TYPES = [ RFC2079_OBJECT_CLASSES = [ "( 1.3.6.1.4.1.250.3.15 NAME 'labeledURIObject' DESC 'object that contains the URI attribute type' SUP top AUXILIARY MAY labeledURI )", ] +#: :any:`Schema` implementing the labeledURIObject object class and the +#: labeledURI attribute type. RFC2079_SCHEMA = CORE_SCHEMA.extend(attribute_type_definitions=RFC2079_ATTRIBUTE_TYPES, object_class_definitions=RFC2079_OBJECT_CLASSES) RFC2798_ATTRIBUTE_TYPES = [ @@ -196,7 +207,11 @@ RFC2798_ATTRIBUTE_TYPES = [ RFC2798_OBJECT_CLASSES = [ "( 2.16.840.1.113730.3.2.2 NAME 'inetOrgPerson' SUP organizationalPerson STRUCTURAL MAY ( audio $ businessCategory $ carLicense $ departmentNumber $ displayName $ employeeNumber $ employeeType $ givenName $ homePhone $ homePostalAddress $ initials $ jpegPhoto $ labeledURI $ mail $ manager $ mobile $ o $ pager $ photo $ roomNumber $ secretary $ uid $ userCertificate $ x500uniqueIdentifier $ preferredLanguage $ userSMIMECertificate $ userPKCS12 ) )", ] -INETORG_SCHMEA = RFC2798_SCHEMA = (RFC4524_SCHEMA|RFC2079_SCHEMA|RFC4523_SCHEMA).extend(attribute_type_definitions=RFC2798_ATTRIBUTE_TYPES, object_class_definitions=RFC2798_OBJECT_CLASSES) +#: :any:`Schema` implementing the inetOrgPerson object class and its +#: attribute types. +RFC2798_SCHEMA = (RFC4524_SCHEMA|RFC2079_SCHEMA|RFC4523_SCHEMA).extend(attribute_type_definitions=RFC2798_ATTRIBUTE_TYPES, object_class_definitions=RFC2798_OBJECT_CLASSES) +#: +INETORG_SCHMEA = RFC2798_SCHEMA RFC2307BIS_ATTRIBUTE_TYPES = [ "( 1.3.6.1.1.1.1.0 NAME 'uidNumber' DESC 'An integer uniquely identifying a user in an administrative domain' EQUALITY integerMatch ORDERING integerOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )", @@ -253,4 +268,5 @@ RFC2307BIS_OBJECT_CLASSES = [ "( 1.3.6.1.1.1.2.17 NAME 'automount' DESC 'Automount information' SUP top STRUCTURAL MUST ( automountKey $ automountInformation ) MAY description )", "( 1.3.6.1.1.1.2.18 NAME 'groupOfMembers' DESC 'A group with members (DNs)' SUP top STRUCTURAL MUST cn MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description $ member ) )", ] +#: :any:`Schema` implementing draft-howard-rfc2307bis-02 (updated/extended NIS schema) RFC2307BIS_SCHEMA = (RFC4524_SCHEMA|RFC3112_SCHEMA).extend(attribute_type_definitions=RFC2307BIS_ATTRIBUTE_TYPES, object_class_definitions=RFC2307BIS_OBJECT_CLASSES) diff --git a/ldapserver/schema/definitions.py b/ldapserver/schema/definitions.py index c52c745..3943cda 100644 --- a/ldapserver/schema/definitions.py +++ b/ldapserver/schema/definitions.py @@ -152,12 +152,25 @@ def parse_extensions(tokens): class SyntaxDefinition: def __init__(self, oid, desc='', extensions=None, extra_compatability_tags=None): + #: Numeric OID (string) self.oid = oid + #: Description (string, empty if there is none) self.desc = desc + #: self.extensions = extensions or {} + #: Set of compatability tags (strings, usually numeric OIDs). Syntaxes + #: always have at least their own numeric OID as a compatability tag. + #: + #: A matching rule can be applied to the values of a syntax if the matching + #: rules compatability tag is one of the syntaxes compatability tags. self.compatability_tags = {oid} | set(extra_compatability_tags or tuple()) def __str__(self): + '''Return LDAP syntax definition string according to RFC4512 + + Example: + + ( 1.3.6.1.4.1.1466.115.121.1.15 DESC 'Directory String' )''' tokens = ['(', self.oid] if self.desc: tokens += ['DESC', qdstring_to_token(self.desc)] @@ -166,6 +179,7 @@ class SyntaxDefinition: @property def first_component_oid(self): + '''Used by objectIdentifierFirstComponentMatch matching rule''' return self.oid def encode(self, schema, value): @@ -194,8 +208,12 @@ class SyntaxDefinition: raise exceptions.LDAPInvalidAttributeSyntax() class MatchingRuleKind(enum.Enum): + '''Values for :any:`MatchingRuleDefinition.kind`''' + #: EQUALITY = enum.auto() + #: ORDERING = enum.auto() + #: SUBSTR = enum.auto() class MatchingRuleDefinition: @@ -205,16 +223,33 @@ class MatchingRuleDefinition: raise ValueError('syntax must be specified') if not kind: raise ValueError('kind must be specified') + #: Numeric OID (string) self.oid = oid + #: OID of assertion value syntax (string) self.syntax = syntax + #: Short descriptive names (list of strings, may be empty) self.name = name or [] + #: Description (string, empty if there is none) self.desc = desc + #: bool self.obsolete = obsolete + #: self.extensions = extensions or {} + #: Compatability tag (string, usually a numeric OIDs). Defaults to the OID + #: of its syntax. + #: + #: A matching rule can be applied to the values of a syntax if the matching + #: rules compatability tag is one of the syntaxes compatability tags. self.compatability_tag = compatability_tag or syntax + #: self.kind = kind def __str__(self): + '''Return matching rule definition string according to RFC4512 + + Example: + + ( 2.5.13.2 NAME 'caseIgnoreMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )''' tokens = ['(', self.oid] if self.name: tokens += ['NAME'] + qdescrs_to_tokens(self.name) @@ -228,10 +263,11 @@ class MatchingRuleDefinition: @property def first_component_oid(self): + '''Used by objectIdentifierFirstComponentMatch matching rule''' return self.oid def match_equal(self, schema, attribute_values, assertion_value): - '''Return whether any attribute value equals assertion value + '''Return whether any attribute value equals the assertion value Only available for EQUALITY matching rules. @@ -241,7 +277,7 @@ class MatchingRuleDefinition: raise exceptions.LDAPInappropriateMatching() def match_approx(self, schema, attribute_values, assertion_value): - '''Return whether any attribute value approximatly equals assertion value + '''Return whether any attribute value approximatly equals the assertion value Only available for EQUALITY matching rules. @@ -271,7 +307,7 @@ class MatchingRuleDefinition: raise exceptions.LDAPInappropriateMatching() def match_substr(self, schema, attribute_values, inital_substring, any_substrings, final_substring): - '''Return whether any attribute value matches a substring assertion + '''Return whether any attribute value matches the substring assertion Only available for SUBSTR matching rules. @@ -295,14 +331,26 @@ class MatchingRuleDefinition: class MatchingRuleUseDefinition: def __init__(self, oid, name=None, desc='', obsolete=False, applies=None, extensions=None): + #: Numeric OID (string) self.oid = oid + #: Short descriptive names (list of strings, may be empty) self.name = name or [] + #: Description (string, empty if there is none) self.desc = desc + #: bool self.obsolete = obsolete + #: OIDs or short descriptive names of attribute types the matching rule can + #: be applied to (list of strings) self.applies = applies or [] + #: self.extensions = extensions or {} def __str__(self): + '''Return matching rule use definition string according to RFC4512 + + Example: + + ( 2.5.13.0 NAME 'objectIdentifierMatch' APPLIES ( supportedControl $ supportedExtension $ supportedFeatures $ supportedApplicationContext ) )''' tokens = ['(', self.oid] if self.name: tokens += ['NAME'] + qdescrs_to_tokens(self.name) @@ -316,19 +364,20 @@ class MatchingRuleUseDefinition: @property def first_component_oid(self): + '''Used by objectIdentifierFirstComponentMatch matching rule''' return self.oid class AttributeTypeUsage(enum.Enum): - '''Values for usage argument of `AttributeTypeUsage`''' + '''Values for :any:`AttributeTypeDefinition.usage`''' # pylint: disable=invalid-name - # user + #: user (not an operational attribute) userApplications = enum.auto() - # directory operational + #: directory operational directoryOperation = enum.auto() - # DSA-shared operational + #: DSA-shared operational distributedOperation = enum.auto() - # DSA-specific operational + #: DSA-specific operational dSAOperation = enum.auto() class AttributeTypeDefinition: @@ -340,23 +389,46 @@ class AttributeTypeDefinition: usage=AttributeTypeUsage.userApplications, extensions=None): if not sup and not syntax: raise ValueError('Either SUP or SYNTAX must be specified') + #: Numeric OID (string) self.oid = oid + #: Short descriptive names (list of strings, may be empty) self.name = name or [] + #: Description (string, empty if there is none) self.desc = desc + #: bool self.obsolete = obsolete + #: OID or short descriptive name of superior attribute type (string or None) self.sup = sup + #: OID or short descriptive name of equality matching rule (string or None) self.equality = equality + #: OID or short descriptive name of ordering matching rule (string or None) self.ordering = ordering + #: OID or short descriptive name of substrings matching rule (string or None) self.substr = substr + #: OID of attribute value syntax (string or None) self.syntax = syntax + #: Suggested minimum upper bound for attribute value length (int or None) self.syntax_len = syntax_len + #: Whether attribute values are restricted to a single value (bool) self.single_value = single_value + #: bool self.collective = collective + #: bool self.no_user_modification = no_user_modification + #: Value of :class:`AttributeTypeUsage` self.usage = usage + #: self.extensions = extensions or {} def __str__(self): + '''Return attribute type definition string according to RFC4512 + + Example: + + ( 2.5.4.3 NAME ( 'cn' 'commonName' ) DESC 'RFC4519: common name(s) for which the entity is known by' SUP name ) + + The string can be decoded into an equalivalent + :class:`AttributeTypeDefinition` object with :any:`from_str`.''' tokens = ['(', self.oid] if self.name: tokens += ['NAME'] + qdescrs_to_tokens(self.name) @@ -390,6 +462,12 @@ class AttributeTypeDefinition: @classmethod def from_str(cls, string): + '''Decode attribute type definition string according to RFC4512 + + :returns: Equivalent attribute type definition object + :rtype: AttributeTypeDefinition + + See :any:`__str__` for the string format.''' tokens = tokenize(string) parse_token(tokens, '(') oid = parse_numericoid(tokens) @@ -443,12 +521,16 @@ class AttributeTypeDefinition: @property def first_component_oid(self): + '''Used by objectIdentifierFirstComponentMatch matching rule''' return self.oid class ObjectClassKind(enum.Enum): - '''Values for kind argument of `ObjectClass`''' + '''Values for :any:`ObjectClassDefinition.kind`''' + #: ABSTRACT = enum.auto() + #: STRUCTURAL = enum.auto() + #: AUXILIARY = enum.auto() class ObjectClassDefinition: @@ -456,17 +538,37 @@ class ObjectClassDefinition: def __init__(self, oid, name=None, desc='', obsolete=False, sup=None, kind=ObjectClassKind.STRUCTURAL, must=None, may=None, extensions=None): + #: Numeric OID (string) self.oid = oid + #: Short descriptive names (list of strings, may be empty) self.name = name or [] + #: Description (string, empty if there is none) self.desc = desc + #: bool self.obsolete = obsolete + #: OIDs and short descriptive names of superior object classes (list of + #: strings, may be empty) self.sup = sup or [] + #: Value of :class:`ObjectClassKind` self.kind = kind + #: OIDs and short descriptive names of attribute types entries with the + #: object class must have (list of strings, may be empty) self.must = must or [] + #: OIDs and short descriptive names of attribute types entries with the + #: object class may have (list of strings, may be empty) self.may = may or [] + #: self.extensions = extensions or {} def __str__(self): + '''Return object class definition string according to RFC4512 + + Example: + + ( 2.5.6.0 NAME 'top' DESC 'top of the superclass chain' ABSTRACT MUST objectClass ) + + The string can be decoded into an equalivalent + :class:`ObjectClassDefinition` object with :any:`from_str`.''' tokens = ['(', self.oid] if self.name: tokens += ['NAME'] + qdescrs_to_tokens(self.name) @@ -486,6 +588,12 @@ class ObjectClassDefinition: @classmethod def from_str(cls, string): + '''Decode object class definition string according to RFC4512 + + :returns: Equivalent object class definition object + :rtype: ObjectClassDefinition + + See :any:`__str__` for the string format.''' tokens = tokenize(string) parse_token(tokens, '(') oid = parse_numericoid(tokens) @@ -521,4 +629,5 @@ class ObjectClassDefinition: @property def first_component_oid(self): + '''Used by objectIdentifierFirstComponentMatch matching rule''' return self.oid diff --git a/ldapserver/schema/types.py b/ldapserver/schema/types.py index c5f24df..8eeac16 100644 --- a/ldapserver/schema/types.py +++ b/ldapserver/schema/types.py @@ -18,40 +18,65 @@ __all__ = [ class Syntax: '''LDAP syntax for attribute and assertion values''' def __init__(self, schema, definition, syntaxes_by_tag): + #: self.schema = schema + #: Corresponding :class:`SyntaxDefinition` object self.definition = definition + #: Numeric OID of syntax (string, e.g. ``'1.3.6.1.4.1.1466.115.121.1.15'``) self.oid = definition.oid + #: Preferred name for syntax (same as :any:`oid`) self.ref = self.oid schema._register(self, self.oid, self.ref) schema.syntaxes._register(self, self.oid, self.ref) for tag in definition.compatability_tags: syntaxes_by_tag.setdefault(tag, set()).add(self) # Populated by MatchingRule.__init__ + #: Set of all matching rules wihin the schema that can be applied to values of the syntax self.compatible_matching_rules = set() def __repr__(self): - return f'<ldapserver.schema.Syntax {self.oid}>' + return f'<ldapserver.schema.Syntax {self.ref}>' def encode(self, value): + '''Encode value to its LDAP-specific encoding + + :param value: Native value + :type value: any + :rtype: bytes''' return self.definition.encode(self.schema, value) def decode(self, raw_value): + '''Decode LDAP-specific encoding of a value + + :param raw_value: LDAP-specific encoding + :type raw_value: bytes + :rtype: any''' return self.definition.decode(self.schema, raw_value) class MatchingRule: def __init__(self, schema, definition, syntaxes_by_tag): + #: self.schema = schema + #: Corresponding :class:`MatchingRuleDefinition` object self.definition = definition + #: Numeric OID of matching rule (string, e.g. ``'2.5.13.2'``) self.oid = definition.oid + #: Syntax of assertion values. For + #: :any:`schema.SubstrMatchingRule.match_substr` the syntax of the + #: attribute's equality matching rule is used instead. self.syntax = schema.syntaxes[definition.syntax] + #: List of short descriptive names of the matching rule (list of strings, e.g. ``['caseIgnoreMatch']``) self.names = self.definition.name + #: Preferred name of matching rule (first item of :any:`names` or :any:`oid`, e.g. ``'caseIgnoreMatch'``) self.ref = self.names[0] if self.names else self.oid schema._register(self, self.oid, self.ref, *self.names) schema.matching_rules._register(self, self.oid, self.ref, *self.names) + #: Set of all compatible syntaxes within the schema whose values the matching rule can be applied to self.compatible_syntaxes = syntaxes_by_tag.setdefault(definition.compatability_tag, set()) for syntax in self.compatible_syntaxes: syntax.compatible_matching_rules.add(self) # Populated by AttributeType.__init__ + #: Set of all compatible attribute types within the schema whose values the matching rule can be applied to self.compatible_attribute_types = set() def match_extensible(self, attribute_values, assertion_value): @@ -62,12 +87,27 @@ class EqualityMatchingRule(MatchingRule): return f'<ldapserver.schema.EqualityMatchingRule {self.ref}>' def match_extensible(self, attribute_values, assertion_value): + '''Return whether any attribute value equals the assertion value + + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_equal(self.schema, attribute_values, assertion_value) def match_equal(self, attribute_values, assertion_value): + '''Return whether any attribute value equals the assertion value + + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_equal(self.schema, attribute_values, assertion_value) def match_approx(self, attribute_values, assertion_value): + '''Return whether any attribute value approximatly equals the assertion value + + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_approx(self.schema, attribute_values, assertion_value) class OrderingMatchingRule(MatchingRule): @@ -75,9 +115,19 @@ class OrderingMatchingRule(MatchingRule): return f'<ldapserver.schema.OrderingMatchingRule {self.ref}>' def match_less(self, attribute_values, assertion_value): + '''Return whether any attribute value is less than assertion value + + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_less(self.schema, attribute_values, assertion_value) def match_greater_or_equal(self, attribute_values, assertion_value): + '''Return whether any attribute value is greater than or equal to assertion value + + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_greater_or_equal(self.schema, attribute_values, assertion_value) class SubstrMatchingRule(MatchingRule): @@ -85,37 +135,79 @@ class SubstrMatchingRule(MatchingRule): return f'<ldapserver.schema.SubstrMatchingRule {self.ref}>' def match_extensible(self, attribute_values, assertion_value): + '''Return whether any attribute value matches the substring assertion in assertion_value + + :param attribute_values: Attribute values (type according to attribute's syntax) + :type attribute_values: List of any + :param assertion_value: 3-tuple with initial substring, any substrings and final substring + :type assertion_value: Tuple[any or None, List[any] or None, any or None] + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_substr(self.schema, attribute_values, assertion_value[0], assertion_value[1], assertion_value[2]) def match_substr(self, attribute_values, inital_substring, any_substrings, final_substring): + '''Return whether any attribute value matches the substring assertion + + The type of `inital_substring`, `any_substrings` and `final_substring` + depends on the syntax of the attribute's equality matching rule! + + :param attribute_values: Attribute values (type according to attribute's syntax) + :type attribute_values: List of any + :param inital_substring: Substring to match the beginning (optional) + :type inital_substring: any + :param any_substrings: List of substrings to match between initial and final in order + :type any_substrings: list of any + :param final_substring: Substring to match the end (optional) + :type final_substring: any + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' return self.definition.match_substr(self.schema, attribute_values, inital_substring, any_substrings, final_substring) class AttributeType: def __init__(self, schema, definition): + #: self.schema = schema + #: Corresponding :class:`AttributeTypeDefinition` object self.definition = definition + #: Numeric OID of the attribute type (string, e.g. ``'2.5.4.3'``) self.oid = definition.oid + #: List of short descriptive names of the attribute type (list of strings, e.g. ``['cn', 'commonName']``) self.names = definition.name or [] + #: Preferred name of attribute type (first item of :any:`names` or :any:`oid`, e.g. ``'cn'``) self.ref = self.names[0] if self.names else self.oid + #: Superior :class:`AttributeType` or `None` self.sup = schema.attribute_types[definition.sup] if definition.sup else None + #: Set of subordinate attribute types self.subtypes = set() sup = self.sup while sup: self.sup.subtypes.add(self) sup = sup.sup + #: :class:`EqualityMatchingRule` of the attribute type or `None` if the + #: attribute type has no equality matching rule self.equality = schema.matching_rules[definition.equality] if definition.equality else None + #: :class:`OrderingMatchingRule` of the attribute type or `None` if the + #: attribute type has no ordering matching rule self.ordering = schema.matching_rules[definition.ordering] if definition.ordering else None + #: :class:`SubstrMatchingRule` of the attribute type or `None` if the + #: attribute type has no substring matching rule self.substr = schema.matching_rules[definition.substr] if definition.substr else None if self.sup: self.equality = self.equality or self.sup.equality self.ordering = self.ordering or self.sup.ordering self.substr = self.substr or self.sup.substr + #: :class:`Syntax` of attribute values self.syntax = schema.syntaxes[definition.syntax] if definition.syntax else self.sup.syntax + #: :any:`True` if attribute type is operational, :any:`False` if it is a + #: user application attribute type. self.is_operational = (definition.usage != AttributeTypeUsage.userApplications) schema._register(self, self.oid, self.ref, *self.names) schema.attribute_types._register(self, self.oid, self.ref, *self.names) if not self.is_operational: schema.user_attribute_types.add(self) + #: Set of all matching rules in schema that can be applied to values of the attribute type self.compatible_matching_rules = self.syntax.compatible_matching_rules for matching_rule in self.compatible_matching_rules: matching_rule.compatible_attribute_types.add(self) @@ -124,13 +216,23 @@ class AttributeType: return f'<ldapserver.schema.AttributeType {self.ref}>' def encode(self, value): + '''Encode attribute value to its LDAP-specific encoding + + :param value: Native value + :type value: any + :rtype: bytes''' return self.syntax.encode(value) def decode(self, raw_value): + '''Decode LDAP-specific encoding of an attribute value + + :param raw_value: LDAP-specific encoding + :type raw_value: bytes + :rtype: any''' return self.syntax.decode(raw_value) def match_equal(self, attribute_values, assertion_value): - '''Return whether any attribute value equals assertion value + '''Return whether any attribute value equals the assertion value :param attribute_values: Attribute values (type according to syntax) :type attribute_values: List of any @@ -145,7 +247,7 @@ class AttributeType: return self.equality.match_equal(attribute_values, assertion_value) def match_substr(self, attribute_values, inital_substring, any_substrings, final_substring): - '''Return whether any attribute value matches a substring assertion + '''Return whether any attribute value matches the substring assertion :param attribute_values: Attribute values (type according to syntax) :type attribute_values: List of any @@ -168,7 +270,7 @@ class AttributeType: return self.substr.match_substr(attribute_values, inital_substring, any_substrings, final_substring) def match_approx(self, attribute_values, assertion_value): - '''Return whether any attribute value approximatly equals assertion value + '''Return whether any attribute value approximatly equals the assertion value :param attribute_values: Attribute values (type according to syntax) :type attribute_values: List of any @@ -183,6 +285,16 @@ class AttributeType: return self.equality.match_approx(attribute_values, assertion_value) def match_greater_or_equal(self, attribute_values, assertion_value): + '''Return whether any attribute value is greater than or equal to assertion value + + :param attribute_values: Attribute values (type according to syntax) + :type attribute_values: List of any + :param assertion_value: Assertion value + :type assertion_value: bytes + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' + if self.ordering is None: raise exceptions.LDAPInappropriateMatching() assertion_value = self.ordering.syntax.decode(assertion_value) @@ -195,6 +307,16 @@ class AttributeType: return self.ordering.match_less(attribute_values, assertion_value) def match_less_or_equal(self, attribute_values, assertion_value): + '''Return whether any attribute value is less than or equal to assertion value + + :param attribute_values: Attribute values (type according to syntax) + :type attribute_values: List of any + :param assertion_value: Assertion value + :type assertion_value: bytes + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' + equal_exc = None try: if self.match_equal(attribute_values, assertion_value): @@ -208,6 +330,17 @@ class AttributeType: return False def match_extensible(self, attribute_values, assertion_value, matching_rule=None): + '''Return whether any attribute value matches the assertion value + + :param attribute_values: Attribute values (type according to syntax) + :type attribute_values: List of any + :param assertion_value: Assertion value + :type assertion_value: bytes + :param matching_rule: Optional matching rule, if None :any:`AttributeType.equality` is used + :type matching_rule: :class:`EqualityMatchingRule`, :class:`OrderingMatchingRule`, :class:`SubstrMatchingRule` or None + :returns: True if any attribute values matches, False otherwise + :rtype: bool + :raises exceptions.LDAPError: if the result is undefined''' if not matching_rule: matching_rule = self.equality if not matching_rule or matching_rule not in self.compatible_matching_rules: @@ -218,10 +351,15 @@ class AttributeType: class ObjectClass: '''Representation of an object class wihin a schema''' def __init__(self, schema, definition): + #: self.schema = schema + #: Corresponding :class:`AttributeTypeDefinition` object self.definition = definition + #: Numeric OID of object class (string, e.g. ``'2.5.6.0'``) self.oid = definition.oid + #: List of short descriptive names of the object class (list of strings, e.g. ``['top']``) self.names = definition.name + #: Preferred name of object class (first item of :any:`names` or :any:`oid`, e.g. ``'top'``) self.ref = self.names[0] if self.names else self.oid # Lookup dependencies to ensure consistency # pylint: disable=pointless-statement @@ -275,8 +413,17 @@ class OIDDict(collections.abc.Mapping): return self.__numeric_oid_map.get(key, default) class Schema(OIDDict): - '''Collection of LDAP syntaxes, matching rules, attribute types and object - classes forming an LDAP schema.''' + '''Consistent collection of syntaxes, matching rules, attribute types and + object classes forming an LDAP schema + + :param object_class_definitions: List of :class:`ObjectClassDefinition` or :any:`str` + :param attribute_type_definitions: List of :class:`AttributeTypeDefinition` or :any:`str` + :param matching_rule_definitions: List of :class:`MatchingRuleDefinition` + :param syntax_definitions: List of :class:`SyntaxDefinition` + + Is also a mapping of OIDs and short descriptive names to the respective + schema element objects (:any:`Syntax`, ...). + ''' # pylint: disable=too-many-branches,too-many-statements def __init__(self, object_class_definitions=None, attribute_type_definitions=None, matching_rule_definitions=None, syntax_definitions=None): @@ -284,13 +431,16 @@ class Schema(OIDDict): syntaxes_by_tag = {} # Add syntaxes + #: Mapping of syntax OIDs to :any:`Syntax` objects self.syntaxes = OIDDict() for definition in syntax_definitions or []: if definition.oid not in self.syntaxes: Syntax(self, definition, syntaxes_by_tag) + #: Sequence of :any:`SyntaxDefinition` objects self.syntax_definitions = [syntax.definition for syntax in self.syntaxes.values()] # Add matching rules + #: Mapping of matching rule OIDs and short descriptive names to :any:`EqualityMatchingRule`, :any:`OrderingMatchingRule` and :any:`SubstrMatchingRule` objects self.matching_rules = OIDDict() for definition in matching_rule_definitions or []: if definition.kind == MatchingRuleKind.EQUALITY: @@ -303,12 +453,14 @@ class Schema(OIDDict): raise ValueError('Invalid matching rule kind') if definition.oid not in self.matching_rules: cls(self, definition, syntaxes_by_tag) + #: Sequence of :any:`MatchingRuleDefinition` objects self.matching_rule_definitions = [matching_rule.definition for matching_rule in self.matching_rules.values()] # Add attribute types attribute_type_definitions = [AttributeTypeDefinition.from_str(item) if isinstance(item, str) else item for item in attribute_type_definitions or []] + #: Mapping of attribute type OIDs and short descriptive names to :any:`AttributeType` objects self.attribute_types = OIDDict() self.user_attribute_types = set() # Attribute types may refer to other (superior) attribute types. To resolve @@ -328,12 +480,14 @@ class Schema(OIDDict): for definition in attribute_type_definitions: if definition.oid not in self.attribute_types: AttributeType(self, definition) + #: Sequence of :any:`AttributeTypeDefinition` objects self.attribute_type_definitions = [attribute_type.definition for attribute_type in self.attribute_types.values()] # Add object classes object_class_definitions = [ObjectClassDefinition.from_str(item) if isinstance(item, str) else item for item in object_class_definitions or []] + #: Mapping of object class OIDs and short descriptive names to :any:`ObjectClass` objects self.object_classes = OIDDict() # Object classes may refer to other (superior) object classes. To resolve # these dependencies we cycle through the definitions, each time adding @@ -352,9 +506,11 @@ class Schema(OIDDict): for definition in object_class_definitions: if definition.oid not in self.object_classes: ObjectClass(self, definition) + #: Sequence of :any:`ObjectClassDefinition` objects self.object_class_definitions = [object_class.definition for object_class in self.object_classes.values()] # Generate and add matching rules + #: Sequence of :any:`MatchingRuleUseDefinition` objects self.matching_rule_use_definitions = [] for matching_rule in self.matching_rules.values(): definition = matching_rule.definition @@ -367,6 +523,7 @@ class Schema(OIDDict): def extend(self, object_class_definitions=None, attribute_type_definitions=None, matching_rule_definitions=None, syntax_definitions=None): + '''Return new schema with all schema elements and additional ones''' syntax_definitions = self.syntax_definitions + (syntax_definitions or []) matching_rule_definitions = self.matching_rule_definitions + (matching_rule_definitions or []) attribute_type_definitions = self.attribute_type_definitions + (attribute_type_definitions or []) diff --git a/ldapserver/server.py b/ldapserver/server.py index 3a7d5d8..1c58592 100644 --- a/ldapserver/server.py +++ b/ldapserver/server.py @@ -44,6 +44,11 @@ class RequestLogAdapter(logging.LoggerAdapter): return self.extra['trace_id'] + ': ' + msg, kwargs class BaseLDAPRequestHandler(socketserver.BaseRequestHandler): + #: Logger for request processing + #: + #: For every connection the logger object is wrapped with a + #: :any:`logging.LoggerAdapter` that prefixes messages with a unique token + #: for connetcion tracing. logger = logging.getLogger('ldapserver.server') def setup(self): @@ -73,11 +78,6 @@ class BaseLDAPRequestHandler(socketserver.BaseRequestHandler): self.logger.info('Disconnected duration_seconds=%.3f', time_disconnect - time_connect) def handle_message(self, shallowmsg: ldap.ShallowLDAPMessage) -> typing.Iterable[ldap.LDAPMessage]: - '''Handle an LDAP request foobar - - :param shallowmsg: Half-decoded LDAP message to handle - :returns: Response messages - ''' msgtypes = { ldap.BindRequest: (self.handle_bind, ldap.BindResponse), ldap.UnbindRequest: (self.handle_unbind, None), @@ -166,26 +166,19 @@ class BaseLDAPRequestHandler(socketserver.BaseRequestHandler): raise exceptions.LDAPProtocolError() class LDAPRequestHandler(BaseLDAPRequestHandler): - ''' - .. py:attribute:: rootdse - - Special :any:`LDAPObject` that contains information - about the server, such as supported extentions and SASL authentication - mechansims. Attributes can be accessed in a dict-like fashion. - ''' - + #: :class:`SubschemaSubentry` object that describes the schema. Default + #: value uses :any:`schema.RFC4519_SCHEMA`. Returned by :any:`do_search`. subschema = objects.SubschemaSubentry(schema.RFC4519_SCHEMA, 'cn=Subschema', cn=['Subschema']) - ''' - .. py:attribute:: subschema - Special :any:`LDAPObject` that describes the schema. - Per default the subschema includes standard syntaxes, standard matching - rules and objectclasses/attributetypes for the rootdse and subschema. - It does not include objectclasses/attributetypes for actual data - (e.g. users and groups). See :any:`Subschema` for details. - ''' + #: :class:`RootDSE` object containing information about the server, such + #: as supported extentions and SASL authentication mechansims. Content is + #: determined on setup based on the `supports_*` attributes. Returned by + #: :any:`do_search`. + rootdse: typing.Any - static_objects = tuple() + #: Opaque bind/authorization state. Initially `None` and set to `None` on + #: anonymous bind. Set to whatever the `do_bind_*` callbacks return. + bind_object: typing.Any def setup(self): super().setup() @@ -207,9 +200,9 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): self.rootdse['supportedSASLMechanisms'].append('EXTERNAL') self.rootdse['supportedLDAPVersion'] = ['3'] self.bind_object = None - self.bind_sasl_state = None + self.__bind_sasl_state = None # Set to (mechanism, iterator) by handle_bind self.__paged_searches = {} # pagination cookie -> (iterator, orig_op) - self.__paged_cookie_counter = 0 + self.__paged_cookie_counter = 0 # Used to generate unique cookie values def handle_bind(self, op, controls=None): reject_critical_controls(controls) @@ -217,21 +210,21 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): raise exceptions.LDAPProtocolError('Unsupported protocol version') auth = op.authentication # Resume ongoing SASL dialog - if self.bind_sasl_state and isinstance(auth, ldap.SaslCredentials) \ - and auth.mechanism == self.bind_sasl_state[0]: - mechanism, iterator = self.bind_sasl_state - self.bind_sasl_state = None + if self.__bind_sasl_state and isinstance(auth, ldap.SaslCredentials) \ + and auth.mechanism == self.__bind_sasl_state[0]: + mechanism, iterator = self.__bind_sasl_state + self.__bind_sasl_state = None resp_code = ldap.LDAPResultCode.saslBindInProgress try: resp = iterator.send(auth.credentials) - self.bind_sasl_state = (mechanism, iterator) + self.__bind_sasl_state = (mechanism, iterator) except StopIteration as e: resp_code = ldap.LDAPResultCode.success self.bind_object, resp = e.value # pylint: disable=unpacking-non-sequence yield ldap.BindResponse(resp_code, serverSaslCreds=resp) return # If auth type or SASL method changed, abort SASL dialog - self.bind_sasl_state = None + self.__bind_sasl_state = None if isinstance(auth, ldap.SimpleAuthentication): self.bind_object = self.do_bind_simple(op.name, auth.password) yield ldap.BindResponse(ldap.LDAPResultCode.success) @@ -245,7 +238,7 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): resp_code = ldap.LDAPResultCode.saslBindInProgress try: resp = next(iterator) - self.bind_sasl_state = (auth.mechanism, iterator) + self.__bind_sasl_state = (auth.mechanism, iterator) except StopIteration as e: resp_code = ldap.LDAPResultCode.success self.bind_object, resp = e.value # pylint: disable=unpacking-non-sequence @@ -256,7 +249,7 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): def do_bind_simple(self, dn='', password=b''): '''Do LDAP BIND with simple authentication - :param dn: Distinguished name of object to be authenticated or empty + :param dn: Distinguished name of object to be authenticated, may be empty :type dn: str :param password: Password, may be empty :type password: bytes @@ -276,27 +269,21 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): def do_bind_simple_anonymous(self): '''Do LDAP BIND with simple anonymous authentication (`RFC 4513 5.1.1.`_) - :raises exceptions.LDAPError: if authentication failed + :raises exceptions.LDAPInvalidCredentials: if authentication failed + :returns: Bind object on success (see :any:`bind_object`) - :returns: Bind object on success - :rtype: obj - - Calld by :any:`do_bind_simple`. Always returns None.''' + The default implementation always returns None.''' return None def do_bind_simple_unauthenticated(self, dn): '''Do LDAP BIND with simple unauthenticated authentication (`RFC 4513 5.1.2.`_) - :param dn: Distinguished name of the object to be authenticated + :param dn: DN of the object to be authenticated as :type dn: str + :raises exceptions.LDAPInvalidCredentials: if authentication failed + :returns: Bind object on success (see :any:`bind_object`) - :raises exceptions.LDAPError: if authentication failed - - :returns: Bind object on success - :rtype: obj - - Calld by :any:`do_bind_simple`. The default implementation always raises an - :any:`LDAPInvalidCredentials` exception.''' + The default implementation always raises :any:`LDAPInvalidCredentials`.''' raise exceptions.LDAPInvalidCredentials() def do_bind_simple_authenticated(self, dn, password): @@ -305,15 +292,11 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): :param dn: Distinguished name of the object to be authenticated :type dn: str :param password: Password for object - :type dn: bytes - - :raises exceptions.LDAPError: if authentication failed - - :returns: Bind object on success - :rtype: obj + :type password: bytes + :raises exceptions.LDAPInvalidCredentials: if authentication failed + :returns: Bind object on success (see :any:`bind_object`) - Calld by :any:`do_bind_simple`. The default implementation always raises an - `LDAPInvalidCredentials` exception.''' + The default implementation always raises :any:`LDAPInvalidCredentials`.''' raise exceptions.LDAPInvalidCredentials() def do_bind_sasl(self, mechanism, credentials=None, dn=None): @@ -363,6 +346,7 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): return self.do_bind_sasl_external(authzid=credentials), None raise exceptions.LDAPAuthMethodNotSupported() + #: Indicate SASL "ANONYMOUS" support supports_sasl_anonymous = False def do_bind_sasl_anonymous(self, trace_info=None): @@ -377,10 +361,11 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): :returns: Bind object on success :rtype: obj - Calld by :any:`do_bind_sasl`. The default implementation raises an - :any:`LDAPAuthMethodNotSupported` exception.''' + Only called if :any:`supports_sasl_anonymous` is True. + The default implementation raises :any:`LDAPAuthMethodNotSupported`.''' raise exceptions.LDAPAuthMethodNotSupported() + #: Indicate SASL "PLAIN" support supports_sasl_plain = False def do_bind_sasl_plain(self, identity, password, authzid=None): @@ -398,10 +383,11 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): :returns: Bind object on success :rtype: obj - Calld by :any:`do_bind_sasl`. The default implementation raises an - :any:`LDAPAuthMethodNotSupported` exception.''' + Only called if :any:`supports_sasl_plain` is True. + The default implementation raises :any:`LDAPAuthMethodNotSupported`.''' raise exceptions.LDAPAuthMethodNotSupported() + #: Indicate SASL "EXTERNAL" support supports_sasl_external = False def do_bind_sasl_external(self, authzid=None): @@ -418,10 +404,13 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): EXTERNAL is commonly used for TLS client certificate authentication or system user based authentication on UNIX sockets. - Calld by :any:`do_bind_sasl`. The default implementation raises an - :any:`LDAPAuthMethodNotSupported` exception.''' + Only called if :any:`supports_sasl_external` is True. + The default implementation raises :any:`LDAPAuthMethodNotSupported`.''' raise exceptions.LDAPAuthMethodNotSupported() + #: Enable/disable support for "Simple Paged Results Manipulation" control + #: (RFC2696). Paginated search uses :any:`do_search` like non-paginated + #: search does. supports_paged_results = True def __handle_search_paged(self, op, paged_control, controls=None): @@ -488,25 +477,31 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): op.baseObject, op.scope.name, op.filter, ' '.join(op.attributes), entries, time_end - time_start) def do_search(self, baseobj, scope, filterobj): - '''Do LDAP SEARCH operation + '''Return result candidates for a SEARCH operation :param baseobj: Distinguished name of the LDAP entry relative to which the search is to be performed :type baseobj: str :param scope: Search scope - :type scope: SearchScope + :type scope: ldap.SearchScope :param filterobj: Filter object - :type filterobj: Filter - + :type filterobj: ldap.Filter :raises exceptions.LDAPError: on error + :returns: All LDAP objects that might match the parameters of the SEARCH + operation. + :rtype: Iterable of :class:`Object` - :returns: Iterable of dn, attributes tuples + The default implementation yields :any:`rootdse` and :any:`subschema`. + Both are importent for feature detection, so make sure to also return + them (e.g. with ``yield from super().do_search(...)``). - The default implementation returns matching objects from the root dse and - the subschema.''' + For every returned object :any:`Object.search` is called to filter out + non-matching objects and to construct the response. + + Note that if this method is as an iterator, its execution may be paused + for extended periods of time or aborted prematurly.''' yield self.rootdse yield self.subschema - yield from self.static_objects def handle_compare(self, op, controls=None): self.logger.info('COMPRAE request "%s" %s=%s', op.entry, op.ava.attributeDesc, repr(op.ava.assertionValue)) @@ -529,13 +524,12 @@ class LDAPRequestHandler(BaseLDAPRequestHandler): :type attribute: str :param value: Attribute value :type value: bytes - :raises exceptions.LDAPError: on error - :returns: `Object` or None The default implementation calls `do_search` and returns the first object - with the right DN.''' + for which :any:`Object.compare` does not raise + :any:`exceptions.LDAPNoSuchObject` (i.e. the object has the requested DN).''' objs = self.do_search(dn, ldap.SearchScope.baseObject, ldap.FilterPresent(attribute='objectClass')) for obj in objs: try: -- GitLab