--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Samba domain models.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from .auth_policy import AuthenticationPolicy
+from .auth_silo import AuthenticationSilo
+from .claim_type import ClaimType
+from .model import MODELS
+from .user import User
+from .value_type import ValueType
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Authentication policy model.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from enum import IntEnum
+
+from .fields import BooleanField, EnumField, IntegerField, StringField
+from .model import Model
+
+# Ticket-Granting-Ticket lifetimes.
+MIN_TGT_LIFETIME = 45
+MAX_TGT_LIFETIME = 2147483647
+
+
+class StrongNTLMPolicy(IntEnum):
+ DISABLED = 0
+ OPTIONAL = 1
+ REQUIRED = 2
+
+ @classmethod
+ def get_choices(cls):
+ return sorted([choice.capitalize() for choice in cls._member_names_])
+
+ @classmethod
+ def choices_str(cls):
+ return ", ".join(cls.get_choices())
+
+
+class AuthenticationPolicy(Model):
+ description = StringField("description")
+ enforced = BooleanField("msDS-AuthNPolicyEnforced")
+ strong_ntlm_policy = EnumField("msDS-StrongNTLMPolicy", StrongNTLMPolicy)
+ user_allow_ntlm_network_auth = BooleanField(
+ "msDS-UserAllowedNTLMNetworkAuthentication")
+ user_tgt_lifetime = IntegerField("msDS-UserTGTLifetime")
+ service_allow_ntlm_network_auth = BooleanField(
+ "msDS-ServiceAllowedNTLMNetworkAuthentication")
+ service_tgt_lifetime = IntegerField("msDS-ServiceTGTLifetime")
+ computer_tgt_lifetime = IntegerField("msDS-ComputerTGTLifetime")
+
+ @staticmethod
+ def get_base_dn(ldb):
+ """Return the base DN for the AuthenticationPolicy model.
+
+ :param ldb: Ldb connection
+ :return: Dn object of container
+ """
+ base_dn = ldb.get_config_basedn()
+ base_dn.add_child(
+ "CN=AuthN Policies,CN=AuthN Policy Configuration,CN=Services")
+ return base_dn
+
+ @staticmethod
+ def get_object_class():
+ return "msDS-AuthNPolicy"
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Authentication silo model.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from .fields import DnField, BooleanField, StringField
+from .model import Model
+
+
+class AuthenticationSilo(Model):
+ description = StringField("description")
+ enforced = BooleanField("msDS-AuthNPolicySiloEnforced")
+ user_policy = DnField("msDS-UserAuthNPolicy")
+ service_policy = DnField("msDS-ServiceAuthNPolicy")
+ computer_policy = DnField("msDS-ComputerAuthNPolicy")
+ members = DnField("msDS-AuthNPolicySiloMembers", many=True)
+
+ @staticmethod
+ def get_base_dn(ldb):
+ """Return the base DN for the AuthenticationSilo model.
+
+ :param ldb: Ldb connection
+ :return: Dn object of container
+ """
+ base_dn = ldb.get_config_basedn()
+ base_dn.add_child(
+ "CN=AuthN Silos,CN=AuthN Policy Configuration,CN=Services")
+ return base_dn
+
+ @staticmethod
+ def get_object_class():
+ return "msDS-AuthNPolicySilo"
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Claim type model.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from .fields import BooleanField, DnField, IntegerField,\
+ PossibleClaimValuesField, StringField
+from .model import Model
+
+
+class ClaimType(Model):
+ enabled = BooleanField("Enabled")
+ description = StringField("description")
+ display_name = StringField("displayName")
+ claim_attribute_source = DnField("msDS-ClaimAttributeSource")
+ claim_is_single_valued = BooleanField("msDS-ClaimIsSingleValued")
+ claim_is_value_space_restricted = BooleanField(
+ "msDS-ClaimIsValueSpaceRestricted")
+ claim_possible_values = PossibleClaimValuesField("msDS-ClaimPossibleValues")
+ claim_source_type = StringField("msDS-ClaimSourceType")
+ claim_type_applies_to_class = DnField(
+ "msDS-ClaimTypeAppliesToClass", many=True)
+ claim_value_type = IntegerField("msDS-ClaimValueType")
+
+ @staticmethod
+ def get_base_dn(ldb):
+ """Return the base DN for the ClaimType model.
+
+ :param ldb: Ldb connection
+ :return: Dn object of container
+ """
+ base_dn = ldb.get_config_basedn()
+ base_dn.add_child("CN=Claim Types,CN=Claims Configuration,CN=Services")
+ return base_dn
+
+ @staticmethod
+ def get_object_class():
+ return "msDS-ClaimType"
+
+ def __str__(self):
+ return str(self.display_name)
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Model and ORM exceptions.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+class MultipleObjectsReturned(Exception):
+ pass
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Model fields.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from enum import IntEnum
+
+import io
+from abc import ABCMeta, abstractmethod
+from datetime import datetime
+from xml.etree import ElementTree
+
+from ldb import Dn, MessageElement, string_to_time, timestring
+from samba.dcerpc.misc import GUID
+from samba.ndr import ndr_pack, ndr_unpack
+
+
+class Field(metaclass=ABCMeta):
+ """Base class for all fields.
+
+ Each field will need to implement from_db_value and to_db_value.
+
+ A field must correctly support converting both single valued fields,
+ and list type fields.
+
+ The only thing many=True does is say the field "prefers" to be a list,
+ but really any field can be a list or single value.
+ """
+
+ def __init__(self, name, many=False, default=None, hidden=False):
+ """Creates a new field, should be subclassed.
+
+ :param name: Ldb field name.
+ :param many: If true always convert field to a list when loaded.
+ :param default: Default value or callback method (obj is first argument)
+ :param hidden: If this is True, exclude the field when calling as_dict()
+ """
+ self.name = name
+ self.many = many
+ self.hidden = hidden
+
+ # This ensures that fields with many=True are always lists.
+ # If this is inconsistent anywhere, it isn't so great to use.
+ if self.many and default is None:
+ self.default = []
+ else:
+ self.default = default
+
+ @abstractmethod
+ def from_db_value(self, ldb, value):
+ """Converts value read from the database to Python value.
+
+ :param ldb: Ldb connection
+ :param value: MessageElement value from the database
+ :returns: Parsed value as Python type
+ """
+ pass
+
+ @abstractmethod
+ def to_db_value(self, value, flags):
+ """Converts value to database value.
+
+ This should return a MessageElement or None, where None means
+ the field will be unset on the next save.
+
+ :param value: Input value from Python field
+ :param flags: MessageElement flags
+ :returns: MessageElement or None
+ """
+ pass
+
+
+class IntegerField(Field):
+ """A simple integer field, can be an int or list of int."""
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement to int or list of int."""
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [int(item) for item in value]
+ else:
+ return int(value[0])
+
+ def to_db_value(self, value, flags):
+ """Convert int or list of int to MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [str(item) for item in value], flags, self.name)
+ else:
+ return MessageElement(str(value), flags, self.name)
+
+
+class BinaryField(Field):
+ """Similar to StringField but using bytes instead of str.
+
+ This tends to be quite easy because a MessageElement already uses bytes.
+ """
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement to bytes or list of bytes.
+
+ The values on the MessageElement should already be bytes so the
+ cast to bytes() is likely not needed in from_db_value.
+ """
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [bytes(item) for item in value]
+ else:
+ return bytes(value[0])
+
+ def to_db_value(self, value, flags):
+ """Convert bytes or list of bytes to MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [bytes(item) for item in value], flags, self.name)
+ else:
+ return MessageElement(bytes(value), flags, self.name)
+
+
+class StringField(Field):
+ """A simple string field, may contain str or list of str."""
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement to str or list of str."""
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [str(item) for item in value]
+ else:
+ return str(value)
+
+ def to_db_value(self, value, flags):
+ """Convert str or list of str to MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [str(item) for item in value], flags, self.name)
+ else:
+ return MessageElement(str(value), flags, self.name)
+
+
+class EnumField(Field):
+ """A field based around Python's Enum type."""
+
+ def __init__(self, name, enum, many=False, default=None):
+ """Create a new EnumField for the given enum class."""
+ self.enum = enum
+ super().__init__(name, many, default)
+
+ def enum_from_value(self, value):
+ """Return Enum instance from value.
+
+ Has a special case for IntEnum as the constructor only accepts int.
+ """
+ if issubclass(self.enum, IntEnum):
+ return self.enum(int(str(value)))
+ else:
+ return self.enum(str(value))
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement to enum or list of enum."""
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [self.enum_from_value(item) for item in value]
+ else:
+ return self.enum_from_value(value)
+
+ def to_db_value(self, value, flags):
+ """Convert enum or list of enum to MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [str(item.value) for item in value], flags, self.name)
+ else:
+ return MessageElement(str(value.value), flags, self.name)
+
+
+class DateTimeField(Field):
+ """A field for parsing ldb timestamps into Python datetime."""
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement to datetime or list of datetime."""
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [datetime.fromtimestamp(string_to_time(str(item)))
+ for item in value]
+ else:
+ return datetime.fromtimestamp(string_to_time(str(value)))
+
+ def to_db_value(self, value, flags):
+ """Convert datetime or list of datetime to MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [timestring(int(datetime.timestamp(item))) for item in value],
+ flags, self.name)
+ else:
+ return MessageElement(timestring(int(datetime.timestamp(value))),
+ flags, self.name)
+
+
+class RelatedField(Field):
+ """A field that automatically fetches the related objects.
+
+ Use sparingly, can be a little slow. If in doubt just use DnField instead.
+ """
+
+ def __init__(self, name, model, many=False, default=None):
+ """Create a new RelatedField for the given model."""
+ self.model = model
+ super().__init__(name, many, default)
+
+ def from_db_value(self, ldb, value):
+ """Convert Message element to related object or list of objects.
+
+ Note that fetching related items is not using any sort of lazy
+ loading so use this field sparingly.
+ """
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [self.model.get(ldb, dn=Dn(ldb, str(item))) for item in value]
+ else:
+ return self.model.get(ldb, dn=Dn(ldb, str(value)))
+
+ def to_db_value(self, value, flags):
+ """Convert related object or list of objects to MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [str(item.dn) for item in value], flags, self.name)
+ else:
+ return MessageElement(str(value.dn), flags, self.name)
+
+
+class DnField(Field):
+ """A Dn field parses the current field into a Dn object."""
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement to a Dn object or list of Dn objects."""
+ if value is None:
+ return
+ elif isinstance(value, Dn):
+ return value
+ elif len(value) > 1 or self.many:
+ return [Dn(ldb, str(item)) for item in value]
+ else:
+ return Dn(ldb, str(value))
+
+ def to_db_value(self, value, flags):
+ """Convert Dn object or list of Dn objects into a MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [str(item) for item in value], flags, self.name)
+ else:
+ return MessageElement(str(value), flags, self.name)
+
+
+class GUIDField(Field):
+ """A GUID field decodes fields containing binary GUIDs."""
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement with a GUID into a str or list of str."""
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [str(ndr_unpack(GUID, item)) for item in value]
+ else:
+ return str(ndr_unpack(GUID, value[0]))
+
+ def to_db_value(self, value, flags):
+ """Convert str with GUID into MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [ndr_pack(GUID(item)) for item in value], flags, self.name)
+ else:
+ return MessageElement(ndr_pack(GUID(value)), flags, self.name)
+
+
+class BooleanField(Field):
+ """A simple boolean field, can be a bool or list of bool."""
+
+ def from_db_value(self, ldb, value):
+ """Convert MessageElement into a bool or list of bool."""
+ if value is None:
+ return
+ elif len(value) > 1 or self.many:
+ return [str(item) == "TRUE" for item in value]
+ else:
+ return str(value) == "TRUE"
+
+ def to_db_value(self, value, flags):
+ """Convert bool or list of bool into a MessageElement."""
+ if value is None:
+ return
+ elif isinstance(value, list):
+ return MessageElement(
+ [str(bool(item)).upper() for item in value], flags, self.name)
+ else:
+ return MessageElement(str(bool(value)).upper(), flags, self.name)
+
+
+class PossibleClaimValuesField(Field):
+ """Field for parsing possible values XML for claim types.
+
+ This field will be represented by a list of dicts as follows:
+
+ [
+ {"ValueGUID": <GUID>},
+ {"ValueDisplayName: "Display name"},
+ {"ValueDescription: "Optional description or None for no description"},
+ {"Value": <Value>},
+ ]
+
+ Note that the GUID needs to be created client-side when adding entries,
+ leaving it as None then saving it doesn't generate the GUID.
+
+ The field itself just converts the XML to list and vice versa, it doesn't
+ automatically generate GUIDs for entries, this is entirely up to the caller.
+ """
+
+ # Namespaces for PossibleValues xml parsing.
+ NAMESPACE = {
+ "xsd": "http://www.w3.org/2001/XMLSchema",
+ "xsi": "http://www.w3.org/2001/XMLSchema-instance",
+ "": "http://schemas.microsoft.com/2010/08/ActiveDirectory/PossibleValues"
+ }
+
+ def from_db_value(self, ldb, value):
+ """Parse MessageElement with XML to list of dicts."""
+ if value is not None:
+ root = ElementTree.fromstring(str(value))
+ string_list = root.find("StringList", self.NAMESPACE)
+
+ values = []
+ for item in string_list.findall("Item", self.NAMESPACE):
+ values.append({
+ "ValueGUID": item.find("ValueGUID", self.NAMESPACE).text,
+ "ValueDisplayName": item.find("ValueDisplayName",
+ self.NAMESPACE).text,
+ "ValueDescription": item.find("ValueDescription",
+ self.NAMESPACE).text,
+ "Value": item.find("Value", self.NAMESPACE).text,
+ })
+
+ return values
+
+ def to_db_value(self, value, flags):
+ """Convert list of dicts back fo XML as a MessageElement."""
+ if value is None:
+ return
+
+ # Possible values should always be a list of dict, but for consistency
+ # with other fields just wrap a single value into a list and continue.
+ if isinstance(value, list):
+ possible_values = value
+ else:
+ possible_values = [value]
+
+ # No point storing XML of an empty list.
+ # Return None, the field will be unset on the next save.
+ if len(possible_values) == 0:
+ return
+
+ # root node
+ root = ElementTree.Element("PossibleClaimValues")
+ for name, url in self.NAMESPACE.items():
+ if name == "":
+ root.set("xmlns", url)
+ else:
+ root.set(f"xmlns:{name}", url)
+
+ # StringList node
+ string_list = ElementTree.SubElement(root, "StringList")
+
+ # List of values
+ for item_dict in possible_values:
+ item = ElementTree.SubElement(string_list, "Item")
+ item_guid = ElementTree.SubElement(item, "ValueGUID")
+ item_guid.text = item_dict["ValueGUID"]
+ item_name = ElementTree.SubElement(item, "ValueDisplayName")
+ item_name.text = item_dict["ValueDisplayName"]
+ item_desc = ElementTree.SubElement(item, "ValueDescription")
+ item_desc.text = item_dict["ValueDescription"]
+ item_value = ElementTree.SubElement(item, "Value")
+ item_value.text = item_dict["Value"]
+
+ # NOTE: indent was only added in Python 3.9 so can't be used yet.
+ # ElementTree.indent(root, space="\t", level=0)
+
+ out = io.BytesIO()
+ ElementTree.ElementTree(root).write(out,
+ encoding="utf-16",
+ xml_declaration=True,
+ short_empty_elements=False)
+
+ # Back to str as that is what MessageElement needs.
+ return MessageElement(out.getvalue().decode("utf-16"), flags, self.name)
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Model and basic ORM for the Ldb database.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+import inspect
+from abc import ABCMeta, abstractmethod
+
+from ldb import ERR_NO_SUCH_OBJECT, FLAG_MOD_ADD, FLAG_MOD_REPLACE, LdbError,\
+ Message, MessageElement, SCOPE_BASE, SCOPE_SUBTREE, binary_encode
+from samba.sd_utils import SDUtils
+
+from .exceptions import MultipleObjectsReturned
+from .fields import DateTimeField, DnField, Field, GUIDField, IntegerField,\
+ StringField
+
+# Keeps track of registered models.
+# This gets populated by the ModelMeta class.
+MODELS = {}
+
+
+class ModelMeta(ABCMeta):
+
+ def __new__(mcls, name, bases, namespace, **kwargs):
+ cls = super().__new__(mcls, name, bases, namespace, **kwargs)
+
+ if cls.__name__ != "Model":
+ cls.fields = dict(inspect.getmembers(cls, lambda f: isinstance(f, Field)))
+ cls.meta = mcls
+ MODELS[name] = cls
+
+ return cls
+
+
+class Model(metaclass=ModelMeta):
+ cn = StringField("cn")
+ distinguished_name = DnField("distinguishedName")
+ dn = DnField("dn")
+ ds_core_propagation_data = DateTimeField("dsCorePropagationData",
+ hidden=True)
+ instance_type = IntegerField("instanceType")
+ name = StringField("name")
+ object_category = DnField("objectCategory")
+ object_class = StringField("objectClass",
+ default=lambda obj: obj.get_object_class())
+ object_guid = GUIDField("objectGUID")
+ usn_changed = IntegerField("uSNChanged", hidden=True)
+ usn_created = IntegerField("uSNCreated", hidden=True)
+ when_changed = DateTimeField("whenChanged", hidden=True)
+ when_created = DateTimeField("whenCreated", hidden=True)
+
+ def __init__(self, **kwargs):
+ """Create a new model instance and optionally populate fields.
+
+ Does not save the object to the database, call .save() for that.
+
+ :param kwargs: Optional input fields to populate object with
+ """
+ for field_name, field in self.fields.items():
+ if field_name in kwargs:
+ default = kwargs[field_name]
+ elif callable(field.default):
+ default = field.default(self)
+ else:
+ default = field.default
+
+ setattr(self, field_name, default)
+
+ def __repr__(self):
+ """Return object representation for this model."""
+ return f"<{self.__class__.__name__}: {self}>"
+
+ def __str__(self):
+ """Stringify model instance to implement in each model."""
+ return str(self.cn)
+
+ def __eq__(self, other):
+ """Basic object equality check only really checks if the dn matches.
+
+ :param other: The other object to compare with
+ """
+ if other is None:
+ return False
+ else:
+ return self.dn == other.dn
+
+ def __json__(self):
+ """Automatically called by custom JSONEncoder class.
+
+ When turning an object into json any fields of type RelatedField
+ will also end up calling this method.
+ """
+ if self.dn is not None:
+ return str(self.dn)
+
+ @staticmethod
+ @abstractmethod
+ def get_base_dn(ldb):
+ """Return the base DN for the container of this model.
+
+ :param ldb: Ldb connection
+ :return: Dn to use for new objects
+ """
+ pass
+
+ @classmethod
+ def get_search_dn(cls, ldb):
+ """Return the DN used for querying.
+
+ By default, this just calls get_base_dn, but it is possible to
+ return a different Dn for querying.
+
+ :param ldb: Ldb connection
+ :return: Dn to use for searching
+ """
+ return cls.get_base_dn(ldb)
+
+ @staticmethod
+ @abstractmethod
+ def get_object_class():
+ """Returns the objectClass for this model."""
+ pass
+
+ @classmethod
+ def from_message(cls, ldb, message):
+ """Create a new model instance from the Ldb Message object.
+
+ :param ldb: Ldb connection
+ :param message: Ldb Message object to create instance from
+ """
+ obj = cls()
+ obj._apply(ldb, message)
+ return obj
+
+ def _apply(self, ldb, message):
+ """Internal method to apply Ldb Message to current object.
+
+ :param ldb: Ldb connection
+ :param message: Ldb Message object to apply
+ """
+ for attr, field in self.fields.items():
+ if field.name in message:
+ setattr(self, attr, field.from_db_value(ldb, message[field.name]))
+
+ def refresh(self, ldb, fields=None):
+ """Refresh object from database.
+
+ :param ldb: Ldb connection
+ :param fields: Optional list of field names to refresh
+ """
+ attrs = [self.fields[f].name for f in fields] if fields else None
+ res = ldb.search(self.dn, scope=SCOPE_BASE, attrs=attrs)
+ self._apply(ldb, res[0])
+
+ def as_dict(self, include_hidden=False):
+ """Returns a dict representation of the model.
+
+ :param include_hidden: Include fields with hidden=True when set
+ :returns: dict representation of model using Ldb field names as keys
+ """
+ obj_dict = {}
+
+ for attr, field in self.fields.items():
+ if not field.hidden or include_hidden:
+ value = getattr(self, attr)
+ if value is not None:
+ obj_dict[field.name] = value
+
+ return obj_dict
+
+ @classmethod
+ def build_expression(cls, **kwargs):
+ """Build LDAP search expression from kwargs.
+
+ :kwargs: fields to use for expression using model field names
+ """
+ # Take a copy, never modify the original if it can be avoided.
+ # Then always add the object_class to the search criteria.
+ criteria = dict(kwargs)
+ criteria["object_class"] = cls.get_object_class()
+
+ # Build search expression.
+ num_fields = len(criteria)
+ expression = "" if num_fields == 1 else "(&"
+
+ for field_name, value in criteria.items():
+ field = cls.fields.get(field_name)
+ if not field:
+ raise ValueError(f"Unknown field '{field_name}'")
+ expression += f"({field.name}={binary_encode(value)})"
+
+ if num_fields > 1:
+ expression += ")"
+
+ return expression
+
+ @classmethod
+ def query(cls, ldb, **kwargs):
+ """Returns a search query for this model.
+
+ :param ldb: Ldb connection
+ :param kwargs: Search criteria as keyword args
+ """
+ result = ldb.search(cls.get_search_dn(ldb),
+ scope=SCOPE_SUBTREE,
+ expression=cls.build_expression(**kwargs))
+
+ # For now this returns a simple generator of model instances.
+ # This could eventually become a QuerySet class if we need to add
+ # additional methods on the return value for example .order_by()
+ for message in result:
+ yield cls.from_message(ldb, message)
+
+ @classmethod
+ def get(cls, ldb, **kwargs):
+ """Get one object, must always return one item.
+
+ Either find object by dn=, or any combination of attributes via kwargs.
+ If there are more than one result, MultipleObjectsReturned is raised.
+
+ :param ldb: Ldb connection
+ :param kwargs: Search criteria as keyword args
+ :returns: User object or None if not found
+ :raises: MultipleObjects returned if there are more than one results
+ """
+ # If a DN is provided use that to get the object directly.
+ # Otherwise, build a search expression using kwargs provided.
+ dn = kwargs.get("dn")
+
+ if dn:
+ # Handle LDAP error 32 LDAP_NO_SUCH_OBJECT, but raise for the rest.
+ # Return None if the User does not exist.
+ try:
+ res = ldb.search(dn, scope=SCOPE_BASE)
+ except LdbError as e:
+ if e.args[0] == ERR_NO_SUCH_OBJECT:
+ return None
+ else:
+ raise
+ else:
+ res = ldb.search(cls.get_search_dn(ldb),
+ scope=SCOPE_SUBTREE,
+ expression=cls.build_expression(**kwargs))
+
+ # Expect to get one object back or raise MultipleObjectsReturned.
+ # For multiple records, please call .query() instead.
+ count = len(res)
+ if count > 1:
+ raise MultipleObjectsReturned(
+ f"More than one object returned (got {count}).")
+ elif count == 1:
+ return cls.from_message(ldb, res[0])
+
+ @classmethod
+ def create(cls, ldb, **kwargs):
+ """Create object constructs object and calls save straight after.
+
+ :param ldb: Ldb connection
+ :param kwargs: Fields to populate object from
+ :returns: object
+ """
+ obj = cls(**kwargs)
+ obj.save(ldb)
+ return obj
+
+ @classmethod
+ def get_or_create(cls, ldb, defaults=None, **kwargs):
+ """Retrieve object and if it doesn't exist create a new instance.
+
+ :param ldb: Ldb connection
+ :param defaults: Attributes only used for create but not search
+ :param kwargs: Attributes used for searching existing object
+ :returns: (object, bool created)
+ """
+ obj = cls.get(ldb, **kwargs)
+ if obj is None:
+ attrs = dict(kwargs)
+ if defaults is not None:
+ attrs.update(defaults)
+ return cls.create(ldb, **attrs), True
+ else:
+ return obj, False
+
+ def save(self, ldb):
+ """Save model to Ldb database.
+
+ The save operation will save all fields excluding fields that
+ return None when calling their `to_db_value` methods.
+
+ The `to_db_value` method can either return a ldb Message object,
+ or None if the field is to be excluded.
+
+ For updates, the existing object is fetched and only fields
+ that are changed are included in the update ldb Message.
+
+ Also for updates, any fields that currently have a value,
+ but are to be set to None will be seen as a delete operation.
+
+ After the save operation the object is refreshed from the server,
+ as often the server will populate some fields.
+
+ :param ldb: Ldb connection
+ """
+ if self.dn is None:
+ dn = self.get_base_dn(ldb)
+ dn.add_child(f"CN={self.cn or self.name}")
+ self.dn = dn
+
+ message = Message(dn=self.dn)
+ for attr, field in self.fields.items():
+ if attr != "dn":
+ value = getattr(self, attr)
+ db_value = field.to_db_value(value, FLAG_MOD_ADD)
+
+ # Don't add empty fields.
+ if db_value is not None and len(db_value):
+ message.add(db_value)
+
+ # Create object
+ ldb.add(message)
+
+ # Fetching object refreshes any automatically populated fields.
+ res = ldb.search(dn, scope=SCOPE_BASE)
+ self._apply(ldb, res[0])
+ else:
+ # Fetch existing object to work out what fields changed.
+ existing_msg = ldb.search(self.dn, scope=SCOPE_BASE)
+ existing_obj = self.from_message(ldb, existing_msg[0])
+
+ # Only modify replace or modify fields that have changed.
+ # Any fields that are set to None or an empty list get unset.
+ message = Message(dn=self.dn)
+ for attr, field in self.fields.items():
+ if attr != "dn":
+ value = getattr(self, attr)
+ old_value = getattr(existing_obj, attr)
+
+ if value != old_value:
+ db_value = field.to_db_value(value, FLAG_MOD_REPLACE)
+
+ # When a field returns None or empty list, delete attr.
+ if db_value in (None, []):
+ db_value = MessageElement([],
+ FLAG_MOD_REPLACE,
+ field.name)
+ message.add(db_value)
+
+ # Saving nothing only triggers an error.
+ if len(message):
+ ldb.modify(message)
+
+ # Fetching object refreshes any automatically populated fields.
+ self.refresh(ldb)
+
+ def delete(self, ldb):
+ """Delete item from Ldb database.
+
+ If self.dn is None then the object has not yet been saved.
+
+ :param ldb: Ldb connection
+ """
+ if self.dn is not None:
+ ldb.delete(self.dn)
+
+ def protect(self, ldb):
+ """Protect object from accidental deletion.
+
+ :param ldb: Ldb connection
+ """
+ utils = SDUtils(ldb)
+ utils.dacl_add_ace(self.dn, "(D;;DTSD;;;WD)")
+
+ def unprotect(self, ldb):
+ """Unprotect object from accidental deletion.
+
+ :param ldb: Ldb connection
+ """
+ utils = SDUtils(ldb)
+ utils.dacl_delete_aces(self.dn, "(D;;DTSD;;;WD)")
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# User model.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from samba.dsdb import DS_GUID_USERS_CONTAINER
+
+from .fields import DnField, StringField
+from .model import Model
+
+
+class User(Model):
+ username = StringField("sAMAccountName")
+ assigned_silo = DnField("msDS-AssignedAuthNPolicySilo")
+
+ @staticmethod
+ def get_base_dn(ldb):
+ """Return the base DN for the User model.
+
+ :param ldb: Ldb connection
+ :return: Dn to use for new objects
+ """
+ return ldb.get_wellknown_dn(ldb.get_default_basedn(),
+ DS_GUID_USERS_CONTAINER)
+
+ @classmethod
+ def get_search_dn(cls, ldb):
+ """Return Dn used for searching so Computers will also be found.
+
+ :param ldb: Ldb connection
+ :return: Dn to use for searching
+ """
+ return ldb.get_root_basedn()
+
+ @staticmethod
+ def get_object_class():
+ return "user"
--- /dev/null
+# Unix SMB/CIFS implementation.
+#
+# Claim value type model.
+#
+# Copyright (C) Catalyst.Net Ltd. 2023
+#
+# Written by Rob van der Linde <rob@catalyst.net.nz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+from .fields import BooleanField, DnField, IntegerField, StringField
+from .model import Model
+
+
+class ValueType(Model):
+ description = StringField("description")
+ display_name = StringField("displayName")
+ claim_is_single_valued = BooleanField("msDS-ClaimIsSingleValued")
+ claim_is_value_space_restricted = BooleanField(
+ "msDS-ClaimIsValueSpaceRestricted")
+ claim_value_type = IntegerField("msDS-ClaimValueType")
+ is_possible_values_present = BooleanField("msDS-IsPossibleValuesPresent")
+ value_type_reference_bl = DnField("msDS-ValueTypeReferenceBL")
+ show_in_advanced_view_only = BooleanField("showInAdvancedViewOnly")
+
+ @staticmethod
+ def get_base_dn(ldb):
+ """Return the base DN for the ValueType model.
+
+ :param ldb: Ldb connection
+ :return: Dn object of container
+ """
+ base_dn = ldb.get_config_basedn()
+ base_dn.add_child("CN=Value Types,CN=Claims Configuration,CN=Services")
+ return base_dn
+
+ @staticmethod
+ def get_object_class():
+ return "msDS-ValueType"
+
+ def __str__(self):
+ return str(self.display_name)