OwlCyberSecurity - MANAGER
Edit File: calendaruserproxy.py
# -*- test-case-name: twistedcaldav.directory.test.test_proxyprincipalmembers -*- ## # Copyright (c) 2006-2017 Apple Inc. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ## """ Implements a calendar user proxy principal. """ __all__ = [ "CalendarUserProxyPrincipalResource", "ProxyDB", "ProxyDBService", "ProxySqliteDB", "ProxyPostgreSQLDB", ] import itertools import uuid from twext.python.log import Logger from twext.who.idirectory import RecordType as BaseRecordType from twisted.internet.defer import succeed, inlineCallbacks, returnValue from twisted.python.modules import getModule from twisted.web.template import XMLFile, Element, renderer from twistedcaldav import customxml from twistedcaldav.customxml import calendarserver_namespace from twistedcaldav.config import config, fullServerPath from twistedcaldav.database import ( AbstractADBAPIDatabase, ADBAPISqliteMixin, ADBAPIPostgreSQLMixin ) from twistedcaldav.directory.util import normalizeUUID from twistedcaldav.directory.util import ( formatLink, formatLinks, formatPrincipals ) from twistedcaldav.extensions import ( DAVPrincipalResource, DAVResourceWithChildrenMixin ) from twistedcaldav.extensions import DirectoryElement from twistedcaldav.extensions import ReadOnlyWritePropertiesResourceMixIn from twistedcaldav.memcacher import Memcacher from twistedcaldav.resource import CalDAVComplianceMixIn from txdav.who.delegates import RecordType as DelegateRecordType from txdav.xml import element as davxml from txdav.xml.base import dav_namespace from txweb2 import responsecode from txweb2.dav.noneprops import NonePropertyStore from txweb2.dav.util import joinURL from txweb2.http import HTTPError, StatusResponse thisModule = getModule(__name__) log = Logger() class PermissionsMixIn (ReadOnlyWritePropertiesResourceMixIn): def defaultAccessControlList(self): aces = ( # DAV:read access for authenticated users. davxml.ACE( davxml.Principal(davxml.Authenticated()), davxml.Grant(davxml.Privilege(davxml.Read())), ), # Inheritable DAV:all access for the resource's associated principal. davxml.ACE( davxml.Principal(davxml.HRef(self.parent.principalURL())), davxml.Grant(davxml.Privilege(davxml.WriteProperties())), davxml.Protected(), ), ) # Add admins aces += tuple(( davxml.ACE( davxml.Principal(davxml.HRef(principal)), davxml.Grant(davxml.Privilege(davxml.All())), davxml.Protected(), ) for principal in config.AdminPrincipals )) return succeed(davxml.ACL(*aces)) def accessControlList(self, request, inheritance=True, expanding=False, inherited_aces=None): # Permissions here are fixed, and are not subject to inheritance rules, etc. return self.defaultAccessControlList() class ProxyPrincipalDetailElement(Element): """ A L{ProxyPrincipalDetailElement} is an L{Element} that can render the details of a L{CalendarUserProxyPrincipalResource}. """ loader = XMLFile(thisModule.filePath.sibling( "calendar-user-proxy-principal-resource.html") ) def __init__(self, resource): super(ProxyPrincipalDetailElement, self).__init__() self.resource = resource @renderer def principal(self, request, tag): """ Top-level renderer in the template. """ record = self.resource.parent.record resource = self.resource parent = self.resource.parent try: if isinstance(record.guid, uuid.UUID): guid = str(record.guid).upper() else: guid = record.guid except AttributeError: guid = "" try: shortNames = record.shortNames except AttributeError: shortNames = [] return tag.fillSlots( directoryGUID=record.service.guid, realm=record.service.realmName, guid=guid, recordType=record.service.recordTypeToOldName(record.recordType), shortNames=shortNames, fullName=record.displayName, principalUID=parent.principalUID(), principalURL=formatLink(parent.principalURL()), proxyPrincipalUID=resource.principalUID(), proxyPrincipalURL=formatLink(resource.principalURL()), alternateURIs=formatLinks(resource.alternateURIs()), groupMembers=resource.groupMembers().addCallback(formatPrincipals), groupMemberships=resource.groupMemberships().addCallback( formatPrincipals ), ) class ProxyPrincipalElement(DirectoryElement): """ L{ProxyPrincipalElement} is a renderer for a L{CalendarUserProxyPrincipalResource}. """ @renderer def resourceDetail(self, request, tag): """ Render the proxy principal's details. """ return ProxyPrincipalDetailElement(self.resource) class CalendarUserProxyPrincipalResource ( CalDAVComplianceMixIn, PermissionsMixIn, DAVResourceWithChildrenMixin, DAVPrincipalResource): """ Calendar user proxy principal resource. """ log = Logger() def __init__(self, parent, proxyType): """ @param parent: the parent of this resource. @param proxyType: a C{str} containing the name of the resource. """ if self.isCollection(): slash = "/" else: slash = "" url = joinURL(parent.principalURL(), proxyType) + slash super(CalendarUserProxyPrincipalResource, self).__init__() DAVResourceWithChildrenMixin.__init__(self) self.parent = parent self.proxyType = proxyType self._url = url # FIXME: if this is supposed to be public, it needs a better name: self.pcollection = self.parent.parent.parent # Principal UID is parent's GUID plus the proxy type; this we can easily # map back to a principal. self.uid = "%s#%s" % (self.parent.principalUID(), proxyType) self._alternate_urls = tuple( joinURL(url, proxyType) + slash for url in parent.alternateURIs() if url.startswith("/") ) def __repr__(self): return "<{}: {}>".format(self.__class__.__name__, str(self)) def __str__(self): return "{} [{}]".format(self.parent, self.proxyType) def _index(self): """ Return the SQL database for this group principal. @return: the L{ProxyDB} for the principal collection. """ return ProxyDBService def resourceType(self): if self.proxyType == "calendar-proxy-read": return davxml.ResourceType.calendarproxyread # @UndefinedVariable elif self.proxyType == "calendar-proxy-write": return davxml.ResourceType.calendarproxywrite # @UndefinedVariable elif self.proxyType == "calendar-proxy-read-for": return davxml.ResourceType.calendarproxyreadfor # @UndefinedVariable elif self.proxyType == "calendar-proxy-write-for": return davxml.ResourceType.calendarproxywritefor # @UndefinedVariable else: return super(CalendarUserProxyPrincipalResource, self).resourceType() def isProxyType(self, read_write): if ( read_write and self.proxyType == "calendar-proxy-write" or not read_write and self.proxyType == "calendar-proxy-read" ): return True else: return False def isCollection(self): return True def etag(self): return succeed(None) def deadProperties(self): if not hasattr(self, "_dead_properties"): self._dead_properties = NonePropertyStore(self) return self._dead_properties @inlineCallbacks def readProperty(self, property, request): if type(property) is tuple: qname = property else: qname = property.qname() namespace, name = qname if namespace == calendarserver_namespace: if name == "record-type": if hasattr(self.parent, "record"): returnValue( customxml.RecordType( self._recordTypeFromProxyType().description ) ) else: raise HTTPError(StatusResponse( responsecode.NOT_FOUND, "Property %s does not exist." % (qname,) )) result = (yield super(CalendarUserProxyPrincipalResource, self).readProperty(property, request)) returnValue(result) def writeProperty(self, property, request): assert isinstance(property, davxml.WebDAVElement) if property.qname() == (dav_namespace, "group-member-set"): return self.setGroupMemberSet(property, request) return super(CalendarUserProxyPrincipalResource, self).writeProperty( property, request) @inlineCallbacks def setGroupMemberSet(self, new_members, request): # FIXME: as defined right now it is not possible to specify a # calendar-user-proxy group as a member of any other group since the # directory service does not know how to lookup these special resource # UIDs. # # Really, c-u-p principals should be treated the same way as any other # principal, so they should be allowed as members of groups. # # This implementation now raises an exception for any principal it # cannot find. # Break out the list into a set of URIs. members = [str(h) for h in new_members.children] # Map the URIs to principals and a set of UIDs. principals = [] newUIDs = set() for uri in members: principal = yield self.pcollection._principalForURI(uri) # Invalid principals MUST result in an error. if principal is None or principal.principalURL() != uri: raise HTTPError(StatusResponse( responsecode.BAD_REQUEST, "Attempt to use a non-existent principal %s " "as a group member of %s." % (uri, self.principalURL(),) )) principals.append(principal) newUIDs.add(principal.principalUID()) # Get the old set of expanded UIDs oldUIDs = set() oldPrincipals = yield self.groupMembers() oldUIDs.update([p.principalUID() for p in oldPrincipals]) oldPrincipals = yield self.expandedGroupMembers() oldUIDs.update([p.principalUID() for p in oldPrincipals]) # Change membership yield self.setGroupMemberSetPrincipals(principals) # Get the new set of UIDs newUIDs = set() newPrincipals = yield self.groupMembers() newUIDs.update([p.principalUID() for p in newPrincipals]) newPrincipals = yield self.expandedGroupMembers() newUIDs.update([p.principalUID() for p in newPrincipals]) # Invalidate the primary principal's cache, and any principal's whose # membership status changed yield self.parent.cacheNotifier.changed() changedUIDs = newUIDs.symmetric_difference(oldUIDs) for uid in changedUIDs: principal = yield self.pcollection.principalForUID(uid) if principal: yield principal.cacheNotifier.changed() returnValue(True) @inlineCallbacks def setGroupMemberSetPrincipals(self, principals): # Find our pseudo-record record = yield self.parent.record.service.recordWithShortName( self._recordTypeFromProxyType(), self.parent.principalUID() ) # Set the members memberRecords = [p.record for p in principals] yield record.setMembers(memberRecords) ## # HTTP ## def htmlElement(self): """ Customize HTML display of proxy groups. """ return ProxyPrincipalElement(self) ## # DAV ## def displayName(self): return self.proxyType ## # ACL ## def alternateURIs(self): # FIXME: Add API to IDirectoryRecord for getting a record URI? return self._alternate_urls def principalURL(self): return self._url def principalUID(self): return self.uid def principalCollections(self): return self.parent.principalCollections() def _recordTypeFromProxyType(self): return { "calendar-proxy-read": DelegateRecordType.readDelegateGroup, "calendar-proxy-write": DelegateRecordType.writeDelegateGroup, "calendar-proxy-read-for": DelegateRecordType.readDelegatorGroup, "calendar-proxy-write-for": DelegateRecordType.writeDelegatorGroup, }.get(self.proxyType) @inlineCallbacks def _directGroupMembers(self, expanded=False): """ Fault in the record representing the sub principal for this proxy type (either read-only or read-write), then fault in the direct members of that record. """ memberPrincipals = [] record = yield self.parent.record.service.recordWithShortName( self._recordTypeFromProxyType(), self.parent.principalUID() ) if record is not None: if expanded: memberRecords = yield record.expandedMembers() else: memberRecords = yield record.members() for record in memberRecords: if record is not None and (record.loginAllowed or record.recordType is BaseRecordType.group): principal = yield self.pcollection.principalForRecord(record) if principal is not None: memberPrincipals.append(principal) returnValue(memberPrincipals) def groupMembers(self): return self._directGroupMembers(expanded=False) def expandedGroupMembers(self): """ Return the complete, flattened set of principals belonging to this group. """ return self._directGroupMembers(expanded=True) def groupMemberships(self): # Unlikely to ever want to put a subprincipal into a group return succeed([]) @inlineCallbacks def containsPrincipal(self, principal): """ Optimize this to ignore the owner principal when doing this test. This is a common occurrence as the owner is the one most likely to be looking at their own resources, each of which will have the owner's two proxy principals listed in the ACLs (and this method is called during each ACL L{checkPrivileges} call. @param principal: The principal to check @type principal: L{DirectoryCalendarPrincipalResource} @return: L{True} if principal is a proxy (of the correct type) of our parent @rtype: C{boolean} """ if principal == self.parent: returnValue(False) # Find our pseudo-record record = yield self.parent.record.service.recordWithShortName( self._recordTypeFromProxyType(), self.parent.principalUID() ) if record is not None: result = yield record.containsUID(principal.principalUID()) else: result = yield super(CalendarUserProxyPrincipalResource, self).containsPrincipal(principal) returnValue(result) class ProxyDB(AbstractADBAPIDatabase): """ A database to maintain calendar user proxy group memberships. SCHEMA: Group Database: ROW: GROUPNAME, MEMBER """ log = Logger() schema_version = "4" schema_type = "CALENDARUSERPROXY" class ProxyDBMemcacher(Memcacher): def __init__(self, namespace): super(ProxyDB.ProxyDBMemcacher, self).__init__(namespace, key_normalization=config.Memcached.ProxyDBKeyNormalization) def setMembers(self, guid, members): return self.set("members:%s" % (str(guid),), str(",".join(members))) def setMemberships(self, guid, memberships): return self.set("memberships:%s" % (str(guid),), str(",".join(memberships))) def getMembers(self, guid): def _value(value): if value: return set(value.split(",")) elif value is None: return None else: return set() d = self.get("members:%s" % (str(guid),)) d.addCallback(_value) return d def getMemberships(self, guid): def _value(value): if value: return set(value.split(",")) elif value is None: return None else: return set() d = self.get("memberships:%s" % (str(guid),)) d.addCallback(_value) return d def deleteMember(self, guid): return self.delete("members:%s" % (str(guid),)) def deleteMembership(self, guid): return self.delete("memberships:%s" % (str(guid),)) def __init__(self, dbID, dbapiName, dbapiArgs, **kwargs): AbstractADBAPIDatabase.__init__(self, dbID, dbapiName, dbapiArgs, True, **kwargs) self._memcacher = ProxyDB.ProxyDBMemcacher("ProxyDB") @inlineCallbacks def setGroupMembers(self, principalUID, members): """ Add a group membership record. @param principalUID: the UID of the group principal to add. @param members: a list UIDs of principals that are members of this group. """ # Get current members before we change them current_members = yield self.getMembers(principalUID) if current_members is None: current_members = () current_members = set(current_members) # Find changes update_members = set(members) remove_members = current_members.difference(update_members) add_members = update_members.difference(current_members) yield self.changeGroupMembersInDatabase(principalUID, add_members, remove_members) # Update cache for member in itertools.chain(remove_members, add_members,): yield self._memcacher.deleteMembership(member) yield self._memcacher.deleteMember(principalUID) @inlineCallbacks def setGroupMembersInDatabase(self, principalUID, members): """ A blocking call to add a group membership record in the database. @param principalUID: the UID of the group principal to add. @param members: a list UIDs of principals that are members of this group. """ # Remove what is there, then add it back. yield self._delete_from_db(principalUID) yield self._add_to_db(principalUID, members) @inlineCallbacks def changeGroupMembersInDatabase(self, principalUID, addMembers, removeMembers): """ A blocking call to add a group membership record in the database. @param principalUID: the UID of the group principal to add. @param addMembers: a list UIDs of principals to be added as members of this group. @param removeMembers: a list UIDs of principals to be removed as members of this group. """ # Remove what is there, then add it back. for member in removeMembers: yield self._delete_from_db_one(principalUID, member) for member in addMembers: yield self._add_to_db_one(principalUID, member) @inlineCallbacks def removeGroup(self, principalUID): """ Remove a group membership record. @param principalUID: the UID of the group principal to remove. """ # Need to get the members before we do the delete members = yield self.getMembers(principalUID) yield self._delete_from_db(principalUID) # Update cache if members: for member in members: yield self._memcacher.deleteMembership(member) yield self._memcacher.deleteMember(principalUID) def getMembers(self, principalUID): """ Return the list of group member UIDs for the specified principal. @return: a deferred returning a C{set} of members. """ def gotCachedMembers(members): if members is not None: return members # Cache miss; compute members and update cache def gotMembersFromDB(dbmembers): members = set([row[0].encode("utf-8") for row in dbmembers]) d = self._memcacher.setMembers(principalUID, members) d.addCallback(lambda _: members) return d d = self.query("select MEMBER from GROUPS where GROUPNAME = :1", (principalUID.decode("utf-8"),)) d.addCallback(gotMembersFromDB) return d d = self._memcacher.getMembers(principalUID) d.addCallback(gotCachedMembers) return d def getMemberships(self, principalUID): """ Return the list of group principal UIDs the specified principal is a member of. @return: a deferred returning a C{set} of memberships. """ def gotCachedMemberships(memberships): if memberships is not None: return memberships # Cache miss; compute memberships and update cache def gotMembershipsFromDB(dbmemberships): memberships = set([row[0].encode("utf-8") for row in dbmemberships]) d = self._memcacher.setMemberships(principalUID, memberships) d.addCallback(lambda _: memberships) return d d = self.query("select GROUPNAME from GROUPS where MEMBER = :1", (principalUID.decode("utf-8"),)) d.addCallback(gotMembershipsFromDB) return d d = self._memcacher.getMemberships(principalUID) d.addCallback(gotCachedMemberships) return d @inlineCallbacks def _add_to_db(self, principalUID, members): """ Insert the specified entry into the database. @param principalUID: the UID of the group principal to add. @param members: a list of UIDs or principals that are members of this group. """ for member in members: yield self.execute( """ insert into GROUPS (GROUPNAME, MEMBER) values (:1, :2) """, (principalUID.decode("utf-8"), member,) ) def _add_to_db_one(self, principalUID, memberUID): """ Insert the specified entry into the database. @param principalUID: the UID of the group principal to add. @param memberUID: the UID of the principal that is being added as a member of this group. """ return self.execute( """ insert into GROUPS (GROUPNAME, MEMBER) values (:1, :2) """, (principalUID.decode("utf-8"), memberUID.decode("utf-8"),) ) def _delete_from_db(self, principalUID): """ Deletes the specified entry from the database. @param principalUID: the UID of the group principal to remove. """ return self.execute("delete from GROUPS where GROUPNAME = :1", (principalUID.decode("utf-8"),)) def _delete_from_db_one(self, principalUID, memberUID): """ Deletes the specified entry from the database. @param principalUID: the UID of the group principal to remove. @param memberUID: the UID of the principal that is being removed as a member of this group. """ return self.execute("delete from GROUPS where GROUPNAME = :1 and MEMBER = :2", (principalUID.decode("utf-8"), memberUID.decode("utf-8"),)) def _delete_from_db_member(self, principalUID): """ Deletes the specified member entry from the database. @param principalUID: the UID of the member principal to remove. """ return self.execute("delete from GROUPS where MEMBER = :1", (principalUID.decode("utf-8"),)) def _db_version(self): """ @return: the schema version assigned to this index. """ return ProxyDB.schema_version def _db_type(self): """ @return: the collection type assigned to this index. """ return ProxyDB.schema_type @inlineCallbacks def _db_init_data_tables(self): """ Initialise the underlying database tables. @param q: a database cursor to use. """ # # GROUPS table # yield self._create_table( "GROUPS", ( ("GROUPNAME", "text"), ("MEMBER", "text"), ), ifnotexists=True, ) yield self._create_index( "GROUPNAMES", "GROUPS", ("GROUPNAME",), ifnotexists=True, ) yield self._create_index( "MEMBERS", "GROUPS", ("MEMBER",), ifnotexists=True, ) @inlineCallbacks def open(self): """ Open the database, normalizing all UUIDs in the process if necessary. """ result = yield super(ProxyDB, self).open() yield self._maybeNormalizeUUIDs() returnValue(result) @inlineCallbacks def _maybeNormalizeUUIDs(self): """ Normalize the UUIDs in the proxy database so they correspond to the normalized UUIDs in the main calendar database. """ alreadyDone = yield self._db_value_for_sql( "select VALUE from CALDAV where KEY = 'UUIDS_NORMALIZED'" ) if alreadyDone is None: for (groupname, member) in ( (yield self._db_all_values_for_sql( "select GROUPNAME, MEMBER from GROUPS")) ): grouplist = groupname.split("#") grouplist[0] = normalizeUUID(grouplist[0]) newGroupName = "#".join(grouplist) newMemberName = normalizeUUID(member) if newGroupName != groupname or newMemberName != member: yield self._db_execute(""" update GROUPS set GROUPNAME = :1, MEMBER = :2 where GROUPNAME = :3 and MEMBER = :4 """, [newGroupName, newMemberName, groupname, member]) yield self._db_execute( """ insert or ignore into CALDAV (KEY, VALUE) values ('UUIDS_NORMALIZED', 'YES') """ ) @inlineCallbacks def _db_upgrade_data_tables(self, old_version): """ Upgrade the data from an older version of the DB. @param old_version: existing DB's version number @type old_version: str """ # Add index if old version is less than "4" if int(old_version) < 4: yield self._create_index( "GROUPNAMES", "GROUPS", ("GROUPNAME",), ifnotexists=True, ) yield self._create_index( "MEMBERS", "GROUPS", ("MEMBER",), ifnotexists=True, ) def _db_empty_data_tables(self): """ Empty the underlying database tables. @param q: a database cursor to use. """ # # GROUPS table # return self._db_execute("delete from GROUPS") @inlineCallbacks def clean(self): if not self.initialized: yield self.open() for group in [row[0] for row in (yield self.query("select GROUPNAME from GROUPS"))]: yield self.removeGroup(group) yield super(ProxyDB, self).clean() @inlineCallbacks def getAllMembers(self): """ Retrieve all members that have been directly delegated to """ returnValue([row[0] for row in (yield self.query("select DISTINCT MEMBER from GROUPS"))]) ProxyDBService = None # Global proxyDB service class ProxySqliteDB(ADBAPISqliteMixin, ProxyDB): """ Sqlite based proxy database implementation. """ def __init__(self, dbpath): self.dbpath = fullServerPath(config.DataRoot, dbpath) ADBAPISqliteMixin.__init__(self) ProxyDB.__init__(self, "Proxies", "sqlite3", (self.dbpath,)) class ProxyPostgreSQLDB(ADBAPIPostgreSQLMixin, ProxyDB): """ PostgreSQL based augment database implementation. """ def __init__(self, host, database, user=None, password=None, dbtype=None): from txdav.base.datastore.subpostgres import postgres ADBAPIPostgreSQLMixin.__init__(self,) ProxyDB.__init__( self, "Proxies", postgres.__name__, (), host=host, database=database, user=user, password=password, ) if dbtype: ProxyDB.schema_type = dbtype def _maybeNormalizeUUIDs(self): """ Don't bother normalizing UUIDs for postgres yet; users of postgres databases for proxy data are even less likely to have UUID case-normalization issues than the general population. """ return succeed(None) ## # Utilities ## authReadACL = davxml.ACL( # Read access for authenticated users. davxml.ACE( davxml.Principal(davxml.Authenticated()), davxml.Grant(davxml.Privilege(davxml.Read())), davxml.Protected(), ), )