Source code for ucsschool.lib.models.share

#!/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 grp
import os.path
from typing import TYPE_CHECKING, Any, List, Optional  # noqa: F401

from ldap.dn import escape_dn_chars
from ldap.filter import filter_format
from six import iteritems

from univention.lib.misc import custom_groupname
from univention.udm import UDM

from ..roles import role_marketplace_share, role_school_class_share, role_workgroup_share
from .attributes import SchoolClassAttribute, ShareName, WorkgroupAttribute
from .base import RoleSupportMixin, UCSSchoolHelperAbstractClass, WrongObjectType
from .utils import _, ucr

if TYPE_CHECKING:
    from .base import LoType, UdmObject  # noqa: F401


[docs] class NoSID(Exception): pass
[docs] class NoSchoolGroup(Exception): pass
[docs] class SetNTACLsMixin(object): """ Mixin for setting NTACLs of UCS@school Share (sub)classes. For example to to prevent students from changing the permissions in a share (Bug #42182). D ~ deny, OI/ OBJECT_INHERIT_ACE ~ Object inheritance, CI/ CONTAINER_INHERIT_ACE ~ container inheritance RC/ READ_CONTROL ~ display security attributes WO/ WRITE_OWNER ~ take ownership WD/ WRITE_DAC ~ write security permissions To make sure, students can edit folders&files in subfolders, they need to inherit edit or full control, since they are denied first. For a complete overview of all options, see https://docs.microsoft.com/en-us/windows/win32/secauthz/ace-strings """
[docs] def get_nt_acls(self, lo): # type: (LoType) -> List[str] return []
[docs] @staticmethod def get_groups_samba_sid(lo, dn): # type: (LoType, str) -> str try: return lo.get(dn)["sambaSID"][0].decode("ASCII") except (IndexError, KeyError): raise NoSID("Group {!r} has no/empty 'sambaSID' attribute.".format(dn))
[docs] def get_ou_admin_full_control(self, lo): # type: (LoType) -> List[str] admin_dn = "cn=admins-{},cn=ouadmins,cn=groups,{}".format( escape_dn_chars(self.school.lower()), ucr.get("ldap/base") ) samba_sid = self.get_groups_samba_sid(lo, admin_dn) return ["(A;OICI;0x001f01ff;;;{})".format(samba_sid)]
[docs] def get_aces_deny_students_change_permissions(self, lo): # type: (LoType) -> List[str] """ Get the schueler-ou sid to deny all students the permissions to modify permissions and take ownership. Derived classes may add more NTACLS. Sets NT ACLs to disallow students to deny students to change the permission of folders, subfolder and files or to take ownership of them as well as displaying them (RC). """ search_base = self.get_search_base(self.school) student_group_dn = "cn={}{},cn=groups,{}".format( escape_dn_chars(search_base.group_prefix_students), escape_dn_chars(self.school), search_base.schoolDN, ) samba_sid = self.get_groups_samba_sid(lo, student_group_dn) return ["(D;OICI;WOWD;;;{})".format(samba_sid)]
[docs] def get_aces_work_group(self, lo): # type: (LoType) -> List[str] """ ACE: deny schueler to change permissions & take ownership ACE: allow workgroup-members to read/write/modify ACE: allow ou-admins full control """ res = self.get_aces_deny_students_change_permissions(lo) if self.school_group: group_dn = self.school_group.dn else: raise NoSchoolGroup("No schoolgroup set.") samba_sid = self.get_groups_samba_sid(lo, group_dn) res.append("(A;OICI;0x001f01ff;;;{})".format(samba_sid)) res.extend(self.get_ou_admin_full_control(lo)) return res
[docs] def get_aces_market_place(self, lo): # type: (LoType) -> List[str] """ ACE: deny schueler to change permissions & take ownership ACE: allow Domain Users to read/write/modify ACE: allow ou-admins full control """ res = self.get_aces_deny_students_change_permissions(lo) search_base = self.get_search_base(self.school) domain_users_dn = "cn=Domain Users %s,%s" % ( escape_dn_chars(self.school.lower()), search_base.groups, ) samba_sid = self.get_groups_samba_sid(lo, domain_users_dn) res.append("(A;OICI;0x001f01ff;;;{})".format(samba_sid)) res.extend(self.get_ou_admin_full_control(lo)) return res
[docs] def get_aces_class_group(self, lo): # type: (LoType) -> List[str] """ ACE: deny schueler to change permissions & take ownership ACE: allow class-members to read/write/modify ACE: allow ou-admins full control """ res = self.get_aces_deny_students_change_permissions(lo) if self.school_group: group_dn = self.school_group.dn else: raise NoSchoolGroup("No schoolgroup set.") samba_sid = self.get_groups_samba_sid(lo, group_dn) res.append("(A;OICI;0x001f01ff;;;{})".format(samba_sid)) res.extend(self.get_ou_admin_full_control(lo)) return res
[docs] def set_nt_acls(self, udm_obj, lo): # type: (UdmObject, LoType) -> None try: udm_obj["appendACL"] = self.get_nt_acls(lo) except NoSID as exc: self.logger.warning("Not setting NTACLs for %s: %s", self.__class__.__name__, exc) return udm_obj["sambaInheritOwner"] = "1" udm_obj["sambaInheritPermissions"] = "1"
[docs] class Share(UCSSchoolHelperAbstractClass): name = ShareName(_("Name")) create_defaults = { "writeable": "1", "sambaWriteable": "1", "sambaBrowseable": "1", "sambaForceGroup": "+{name}", "sambaCreateMode": "0770", "sambaDirectoryMode": "0770", "owner": "0", "group": "0", "directorymode": "0770", } paths = { "no_roleshare": "/home/groups/{name}", "roleshare": "/home/{ou}/groups/{name}", }
[docs] @classmethod def get_container(cls, school): return cls.get_search_base(school).shares
[docs] def do_create(self, udm_obj, lo): for k, v in iteritems(self.create_defaults): udm_obj[k] = v udm_obj["host"] = self.get_server_fqdn(lo) udm_obj["path"] = self.get_share_path() try: udm_obj["sambaForceGroup"] = self.create_defaults["sambaForceGroup"].format(name=self.name) except KeyError: # MarketplaceShare doesn't set this pass udm_obj["group"] = self.get_gid_number(lo) if ucr.is_false("ucsschool/default/share/nfs", True): try: udm_obj.options.remove("nfs") # deactivate NFS except ValueError: pass self.logger.info('Creating share on "%s"', udm_obj["host"]) return super(Share, self).do_create(udm_obj, lo)
[docs] def get_gid_number(self, lo): raise NotImplementedError()
[docs] def get_share_path(self, school=None): school = school or self.school if ucr.is_true("ucsschool/import/roleshare", True): path_template = self.paths["roleshare"] else: path_template = self.paths["no_roleshare"] return path_template.format(ou=school, name=self.name)
[docs] def do_modify(self, udm_obj, lo): old_name = self.get_name_from_dn(self.old_dn) if old_name != self.name: head, tail = os.path.split(udm_obj["path"]) tail = self.name udm_obj["path"] = os.path.join(head, tail) if udm_obj["sambaName"] == old_name: udm_obj["sambaName"] = self.name if udm_obj["sambaForceGroup"] == "+%s" % old_name: udm_obj["sambaForceGroup"] = "+%s" % self.name return super(Share, self).do_modify(udm_obj, lo)
[docs] def get_server_fqdn(self, lo): domainname = ucr.get("domainname") school = self.get_school_obj(lo) school_dn = school.dn # fetch serverfqdn from OU result = lo.get(school_dn, ["ucsschoolClassShareFileServer"]) if result: share_file_server = result["ucsschoolClassShareFileServer"][0].decode("utf-8") server_domain_name = lo.get(share_file_server, ["associatedDomain"]) if server_domain_name: server_domain_name = server_domain_name["associatedDomain"][0].decode("UTF-8") else: server_domain_name = domainname result = lo.get(share_file_server, ["cn"]) if result: return "%s.%s" % (result["cn"][0].decode("UTF-8"), server_domain_name) # get alternative server (defined at ou object if a Replica Directory Node is responsible for # more than one ou) ou_attr_ldap_access_write = lo.get(school_dn, ["univentionLDAPAccessWrite"]) alternative_server_dn = None if len(ou_attr_ldap_access_write) > 0: alternative_server_dn = ou_attr_ldap_access_write["univentionLDAPAccessWrite"][0].decode( "UTF-8" ) if len(ou_attr_ldap_access_write) > 1: self.logger.warning( "more than one corresponding univentionLDAPAccessWrite found at ou=%s", self.school ) # build fqdn of alternative server and set serverfqdn if alternative_server_dn: alternative_server_attr = lo.get(alternative_server_dn, ["uid"]) if len(alternative_server_attr) > 0: alternative_server_uid = alternative_server_attr["uid"][0].decode("utf-8") alternative_server_uid = alternative_server_uid.replace("$", "") if len(alternative_server_uid) > 0: return "%s.%s" % (alternative_server_uid, domainname) # fallback return "%s.%s" % (school.get_dc_name_fallback(), domainname)
[docs] class Meta: udm_module = "shares/share"
[docs] class GroupShare(SetNTACLsMixin, Share): school_group = SchoolClassAttribute(_("School class"), required=True, internal=True)
[docs] @classmethod def from_school_group(cls, school_group): from .group import Group # isort:skip # prevent cyclic import if not isinstance(school_group, Group): raise WrongObjectType(dn=getattr(school_group, "dn", "<no 'dn' attribute>"), cls=Group) return cls(name=school_group.name, school=school_group.school, school_group=school_group)
from_school_class = from_school_group # legacy
[docs] def get_gid_number(self, lo): return self.school_group.get_udm_object(lo)["gidNumber"]
[docs] def get_share_path(self, school=None): school = school or self.school_group.school return super(GroupShare, self).get_share_path(school)
[docs] def do_create(self, udm_obj, lo): self.set_nt_acls(udm_obj, lo) return super(GroupShare, self).do_create(udm_obj, lo)
[docs] class WorkGroupShare(RoleSupportMixin, GroupShare): school_group = WorkgroupAttribute(_("Work group"), required=True, internal=True) default_roles = [role_workgroup_share] _school_in_name_prefix = True
[docs] @classmethod def get_container(cls, school): return cls.get_search_base(school).shares
[docs] @classmethod def get_all(cls, lo, school, filter_str=None, easy_filter=False, superordinate=None): """ This method was overwritten to identify WorkGroupShares and distinct them from other shares of the school. If at some point a lookup is implemented that uses the role attribute which is reliable this code can be removed. Bug #48428 """ shares = super(WorkGroupShare, cls).get_all(lo, school, filter_str, easy_filter, superordinate) filtered_shares = [] search_base = cls.get_search_base(school) for share in shares: groups = ( UDM(lo) .version(1) .get("groups/group") .search(filter_format("name=%s", [share.name]), base=search_base.groups) ) if any((search_base.isWorkgroup(g.dn) for g in groups)): filtered_shares.append(share) return filtered_shares
[docs] def get_nt_acls(self, lo): # type: (LoType) -> List[str] return self.get_aces_work_group(lo)
[docs] class ClassShare(RoleSupportMixin, GroupShare): school_group = SchoolClassAttribute(_("School class"), required=True, internal=True) default_roles = [role_school_class_share] _school_in_name_prefix = True paths = { "no_roleshare": "/home/groups/klassen/{name}", "roleshare": "/home/{ou}/groups/klassen/{name}", }
[docs] @classmethod def get_container(cls, school): return cls.get_search_base(school).classShares
[docs] @classmethod def get_group_class(cls): # prevent import loop from .group import ClassShare # isort:skip return ClassShare
[docs] def get_nt_acls(self, lo): # type: (LoType) -> List[str] return self.get_aces_class_group(lo)
[docs] class MarketplaceShare(RoleSupportMixin, SetNTACLsMixin, Share): default_roles = [role_marketplace_share] _school_in_name_prefix = False def __init__(self, name="Marktplatz", school=None, **kwargs): # type: (Optional[str], Optional[str], **Any) -> None if name != "Marktplatz": raise ValueError("Name of market place must be 'Marktplatz'.") super(MarketplaceShare, self).__init__(name, school, **kwargs)
[docs] @classmethod def get_container(cls, school): return cls.get_search_base(school).shares
[docs] @classmethod def get_all(cls, lo, school, filter_str=None, easy_filter=False, superordinate=None): # ignore all filter arguments, there is only one Marktplatz share per school return super(MarketplaceShare, cls).get_all(lo, school, filter_str="cn=Marktplatz")
[docs] def get_gid_number(self, lo): group_name = ucr.get("ucsschool/import/generate/share/marktplatz/group") or custom_groupname( "Domain Users" ) group_entry = grp.getgrnam(group_name) return str(group_entry.gr_gid)
[docs] def get_share_path(self, school=None): path = ucr.get("ucsschool/import/generate/share/marktplatz/sharepath") if path: return path else: school = school or self.school return super(MarketplaceShare, self).get_share_path(school)
[docs] def do_create(self, udm_obj, lo): self.create_defaults["directorymode"] = ( ucr.get("ucsschool/import/generate/share/marktplatz/permissions") or "0777" ) self.create_defaults.pop("sambaForceGroup", None) self.create_defaults.pop("sambaCreateMode", None) self.create_defaults.pop("sambaDirectoryMode", None) self.set_nt_acls(udm_obj, lo) return super(MarketplaceShare, self).do_create(udm_obj, lo)
[docs] def get_nt_acls(self, lo): # type: (LoType) -> List[str] return self.get_aces_market_place(lo)
[docs] class Meta(Share.Meta): udm_filter = "(&(univentionObjectType=shares/share)(cn=Marktplatz))"