#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# UCS@school python lib: models
#
# Copyright 2014-2025 Univention GmbH
#
# http://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public
# License with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <http://www.gnu.org/licenses/>.
import re
from typing import TYPE_CHECKING, Any, List, Optional, Type # noqa: F401
from ldap.dn import escape_dn_chars
from univention.admin.syntax import (
GroupDN,
MAC_Address,
UDM_Objects,
UserDN,
boolean,
date2,
disabled,
gid,
ipAddress,
ipv4Address,
iso8601Date,
netmask,
primaryEmailAddressValidDomain,
reverseLookupSubnet,
string,
string_numbers_letters_dots_spaces,
uid_umlauts,
v4netmask,
)
from univention.admin.uexceptions import valueError
from ..roles import all_roles
from .utils import _, ucr
if TYPE_CHECKING:
from univention.admin.syntax import simple # noqa: F401
[docs]
class ValidationError(Exception):
pass
[docs]
class Attribute(object):
udm_name = None
syntax = None
extended = False
value_type = None
value_default = None
map_if_none = False
def __init__(
self,
label,
aka=None,
udm_name=None,
required=False,
unlikely_to_change=False,
internal=False,
map_to_udm=True,
):
# type: (str, Optional[List[str]], Optional[str], Optional[bool], Optional[bool], Optional[bool], Optional[bool]) -> None # noqa: E501
self.label = label
self.aka = aka or [] # also_known_as
self.required = required
self.unlikely_to_change = unlikely_to_change
self.internal = internal
self.udm_name = udm_name or self.udm_name
self.map_to_udm = map_to_udm
def _validate_syntax(self, values, syntax=None): # type: (List[Any], Optional[Type[simple]]) -> None
if syntax is None:
syntax = self.syntax
if syntax:
for val in values:
try:
syntax.parse(val)
except valueError as e:
raise ValueError(str(e))
[docs]
def validate(self, value): # type: (Any) -> None
if value is not None:
if self.value_type and not isinstance(value, self.value_type):
raise ValueError(
_('"%(label)s" needs to be a %(type)s')
% {"type": self.value_type.__name__, "label": self.label}
)
values = value if self.value_type else [value]
self._validate_syntax(values)
else:
if self.required:
raise ValueError(_('"%s" is required. Please provide this information.') % self.label)
[docs]
class CommonName(Attribute):
udm_name = "name"
syntax = None
def __init__(self, label, aka=None): # type: (str, Optional[List[str]]) -> None
super(CommonName, self).__init__(label, aka=aka, required=True)
[docs]
def validate(self, value): # type: (str) -> None
super(CommonName, self).validate(value)
escaped = escape_dn_chars(value)
if value != escaped:
raise ValueError(_("May not contain special characters"))
[docs]
def is_valid_win_directory_name(name): # type: (str) -> bool
"""
Check that a string is a valid Windows directory name.
Needed to prevent bug when a user with a reserved name tries to log in to a
Windows computer.
:param name: The name to check.
:returns: True if the name is valid, False otherwise.
"""
# Check if name contains any invalid characters
if re.search(r'[<>:"/\\|?*\x00-\x1F]', name):
return False
# Check if name is a reserved name
reserved_names = [
"CON",
"PRN",
"AUX",
"NUL",
"COM0",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT0",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]
name_without_extension = name.split(".")[0].upper()
if name_without_extension in reserved_names:
return False
# Check if name ends with a space or period
if len(name) > 0 and name[-1] in [" ", "."]:
return False
return not len(name) > 255
[docs]
class Username(CommonName):
udm_name = "username"
syntax = uid_umlauts
[docs]
def validate(self, value):
super(Username, self).validate(value)
if ucr.is_true(
"ucsschool/validation/username/windows-check", True
) and not is_valid_win_directory_name(value):
raise ValueError(_("May not be a Windows reserved name"))
[docs]
class DHCPServerName(CommonName):
udm_name = "server"
[docs]
class DHCPServiceName(CommonName):
udm_name = "service"
[docs]
class GroupName(CommonName):
syntax = gid
[docs]
class SchoolClassName(GroupName):
def _validate_syntax(self, values, syntax=None): # type: (List[Any], Optional[Type[simple]]) -> None
super(SchoolClassName, self)._validate_syntax(values)
# needs to check ShareName.syntax, too: SchoolClass will
# create a share with its own name
super(SchoolClassName, self)._validate_syntax(values, syntax=ShareName.syntax)
[docs]
class ShareName(CommonName):
syntax = string_numbers_letters_dots_spaces
[docs]
class SubnetName(CommonName):
udm_name = "subnet"
syntax = reverseLookupSubnet
[docs]
class DHCPSubnetName(SubnetName):
udm_name = "subnet"
syntax = ipv4Address
[docs]
class SchoolName(CommonName):
udm_name = "name"
[docs]
def validate(self, value): # type: (str) -> None
super(SchoolName, self).validate(value)
regex = re.compile("^[a-zA-Z0-9](([a-zA-Z0-9-_]*)([a-zA-Z0-9]$))?$")
if not regex.match(value):
raise ValueError(_("Invalid school name"))
[docs]
class DCName(Attribute):
[docs]
def validate(self, value): # type: (str) -> None
super(DCName, self).validate(value)
if value:
regex = re.compile("^[a-zA-Z0-9](([a-zA-Z0-9-]*)([a-zA-Z0-9]$))?$")
if not regex.match(value):
raise ValueError(_("Invalid Domain Controller name"))
if ucr.is_true("ucsschool/singlemaster", False):
if len(value) > 13:
raise ValueError(_("A valid NetBIOS hostname can not be longer than 13 characters."))
if sum([len(value), 1, len(ucr.get("domainname", ""))]) > 63:
raise ValueError(
_("The length of fully qualified domain name is greater than 63 characters.")
)
[docs]
class Firstname(Attribute):
udm_name = "firstname"
[docs]
class Lastname(Attribute):
udm_name = "lastname"
[docs]
class Birthday(Attribute):
udm_name = "birthday"
syntax = iso8601Date
map_if_none = True
[docs]
class UserExpirationDate(Attribute):
udm_name = "userexpiry"
syntax = date2
map_if_none = True
[docs]
class Email(Attribute):
udm_name = "mailPrimaryAddress"
syntax = primaryEmailAddressValidDomain
[docs]
def validate(self, value): # type: (str) -> None
if value:
# do not validate ''
super(Email, self).validate(value)
[docs]
class Password(Attribute):
udm_name = "password"
[docs]
class Disabled(Attribute):
udm_name = "disabled"
syntax = disabled
[docs]
class SchoolAttribute(CommonName):
udm_name = None
[docs]
class SchoolClassesAttribute(Attribute):
udm_name = None
value_type = dict
value_default = dict
[docs]
class SchoolClassAttribute(Attribute):
pass
[docs]
class WorkgroupAttribute(Attribute):
pass
[docs]
class WorkgroupsAttribute(Attribute):
udm_name = None
value_type = dict
value_default = dict
[docs]
class Description(Attribute):
udm_name = "description"
[docs]
class DisplayName(Attribute):
udm_name = "displayName"
extended = True
[docs]
class EmptyAttributes(Attribute):
udm_name = "emptyAttributes"
# syntax = dhcp_dnsFixedAttributes # only set internally, no need to use.
# also, it is not part of the "main" syntax.py!
[docs]
class ContainerPath(Attribute):
syntax = boolean
[docs]
class ShareFileServer(Attribute):
syntax = UDM_Objects # UCSSchool_Server_DN is not always available. Easy check: DN
extended = True
[docs]
class Groups(Attribute):
syntax = GroupDN
value_type = list
[docs]
class Users(Attribute):
udm_name = "users"
syntax = UserDN
value_type = list
[docs]
class IPAddress(Attribute):
udm_name = "ip"
syntax = ipAddress
value_type = list
[docs]
class SubnetMask(Attribute):
pass
[docs]
class DHCPSubnetMask(Attribute):
udm_name = "subnetmask"
syntax = v4netmask
[docs]
class DHCPServiceAttribute(Attribute):
pass
[docs]
class BroadcastAddress(Attribute):
udm_name = "broadcastaddress"
syntax = ipv4Address
[docs]
class NetworkBroadcastAddress(Attribute):
syntax = ipv4Address
[docs]
class NetworkAttribute(Attribute):
udm_name = "network"
syntax = ipAddress
[docs]
class Netmask(Attribute):
udm_name = "netmask"
syntax = netmask
[docs]
class MACAddress(Attribute):
udm_name = "mac"
syntax = MAC_Address
value_type = list
[docs]
class InventoryNumber(Attribute):
pass
[docs]
class Hosts(Attribute):
udm_name = "hosts"
value_type = list
syntax = UDM_Objects
[docs]
class Schools(Attribute):
udm_name = "school"
value_type = list
value_default = list
# ucsschoolSchools (cannot be used because it's not available on import time on a unjoined Replica
# Directory Node):
syntax = string
extended = True
[docs]
class RecordUID(Attribute):
udm_name = "ucsschoolRecordUID"
syntax = string
extended = True
[docs]
class SourceUID(Attribute):
udm_name = "ucsschoolSourceUID"
syntax = string
extended = True
[docs]
class RolesSyntax(string):
regex = re.compile(r"^(?P<role>.+):(?P<context_type>.+):(?P<context>.+)$")
[docs]
@classmethod
def parse(cls, text): # type: (str) -> str
if not text:
return text
reg = cls.regex.match(text)
school_context_type = True if reg and reg.groupdict()["context_type"] == "school" else False
if not reg:
raise ValueError(_("Role has bad format"))
if school_context_type and reg.groupdict()["role"] not in all_roles:
raise ValueError(_("Unknown role"))
return super(RolesSyntax, cls).parse(text)
[docs]
class Roles(Attribute):
udm_name = "ucsschoolRole"
value_type = list
value_default = list
syntax = RolesSyntax
extended = True
def __init__(self, *args, **kwargs):
super(Roles, self).__init__(*args, **kwargs)