# -*- test-case-name: xmantissa.test.test_sharing -*- """ This module provides various abstractions for sharing public data in Axiom. """ import os from zope.interface import implementedBy, directlyProvides from twisted.python.reflect import qual, namedAny from axiom import userbase from axiom.item import Item from axiom.attributes import reference, text, AND from axiom.upgrade import registerUpgrader class NoSuchShare(Exception): """ User requested an object that doesn't exist, was not allowed. """ class RoleRelationship(Item): """ RoleRelationship is a bridge record linking member roles with group roles that they are members of. """ schemaVersion = 1 typeName = 'sharing_relationship' member = reference( doc=""" This is a reference to a L{Role} which is a member of my 'group' attribute. """) group = reference( doc=""" This is a reference to a L{Role} which respresents a group that my 'member' attribute is a member of. """) class Role(Item): """ A Role is an identifier for a group or individual which has certain permissions. Items shared within the sharing system are always shared with a particular role. """ schemaVersion = 1 typeName = 'sharing_role' externalID = text( doc=""" This is the external identifier which the role is known by. This field is used to associate users with their primary role. If a user logs in as bob@divmod.com, the sharing system will associate his primary role with the pre-existing role with the externalID of 'bob@divmod.com', or 'Everybody' if no such role exists. For group roles, the externalID is not currently used except as a display identifier. Group roles should never have an '@' character in them, however, to avoid confusion with user roles. """, allowNone=False) # XXX TODO: In addition to the externalID, we really need to have something # that identifies what protocol the user for the role is expected to log in # as, and a way to identify the way that their role was associated with # their login. For example, it might be acceptable for some security # applications (e.g. spam prevention) to simply use an HTTP cookie. For # others (accounting database manipulation) it should be possible to # require more secure methods of authentication, like a signed client # certificate. description = text( doc=""" This is a free-form descriptive string for use by users to explain the purpose of the role. Since the externalID is used by security logic and must correspond to a login identifier, this can be used to hold a user's real name. """) def becomeMemberOf(self, groupRole): """ Instruct this (user or group) Role to become a member of a group role. @param groupRole: The role that this group should become a member of. """ self.store.findOrCreate(RoleRelationship, group=groupRole, member=self) def allRoles(self, memo=None): """ Identify all the roles that this role is authorized to act as. @param memo: used only for recursion. Do not pass this. @return: an iterator of all roles that this role is a member of, including itself. """ if memo is None: memo = set() elif self in memo: # this is bad, but we have successfully detected and prevented the # only really bad symptom, an infinite loop. return memo.add(self) yield self for groupRole in self.store.query(Role, AND(RoleRelationship.member == self, RoleRelationship.group == Role.storeID)): for roleRole in groupRole.allRoles(memo): yield roleRole class _really(object): """ A dynamic proxy for dealing with 'private' attributes on L{SharedProxy}, which overrides C{__getattribute__} itself. This is pretty syntax to avoid ugly references to __dict__ and super and object.__getattribute__() in dynamic proxy implementations. """ def __init__(self, orig): """ Create a _really object with a dynamic proxy. @param orig: an object that overrides __getattribute__, probably L{SharedProxy}. """ self.orig = orig def __getattribute__(self, name): """ Get an attribute present on my original using L{object.__getattribute__}, not the overridden version. """ return object.__getattribute__(object.__getattribute__(self, 'orig'), name) ALLOWED_ON_PROXY = ['__provides__', '__dict__'] class SharedProxy(object): """ A shared proxy is a dynamic proxy which provides exposes methods and attributes declared by shared interfaces on a given Item. These are returned from L{getShare} and yielded from L{asAccessibleTo}. Shared proxies are unlike regular items because they do not have 'storeID' or 'store' attributes (unless explicitly exposed). They are designed to make security easy to implement: if you have a shared proxy, you can access any attribute or method on it without having to do explicit permission checks. If you *do* want to perform an explicit permission check, for example, to render some UI associated with a particular permission, it can be performed as a functionality check instead. For example, C{if getattr(proxy, 'feature', None) is None:} or, more formally, C{IFeature.providedBy(proxy)}. If your interfaces are all declared and implemented properly everywhere, these checks will work both with shared proxies and with the original Items that they represent (but of course, the original Items will always provide all of their features). (Note that object.__getattribute__ still lets you reach inside any object, so don't imagine this makes you bulletproof -- you have to cooperate with it.) """ def __init__(self, sharedItem, sharedInterfaces, shareID): """ Create a shared proxy for a given item. @param sharedItem: The original item that was shared. @param sharedInterfaces: a list of interfaces which C{sharedItem} implements that this proxy should allow access to. @param shareID: the external identifier that the shared item was shared as. """ super(SharedProxy, self).__setattr__('_sharedItem', sharedItem) super(SharedProxy, self).__setattr__('_sharedInterfaces', sharedInterfaces) super(SharedProxy, self).__setattr__('_shareID', shareID) # Make me look *exactly* like the item I am proxying for, at least for # the purposes of adaptation # directlyProvides(self, providedBy(sharedItem)) directlyProvides(self, sharedInterfaces) def __repr__(self): """ Return a pretty string representation of this shared proxy. """ rself = _really(self) return 'SharedProxy(%r, %r, %r)' % ( rself._sharedItem, rself._sharedInterfaces, rself._shareID) def __getattribute__(self, name): """ @return: attributes from my shared item, present in the shared interfaces list for this proxy. @param name: the name of the attribute to retrieve. @raise AttributeError: if the attribute was not found or access to it was denied. """ if name in ALLOWED_ON_PROXY: return object.__getattribute__(self, name) rself = _really(self) if name == 'sharedInterfaces': return rself._sharedInterfaces elif name == 'shareID': return rself._shareID for iface in rself._sharedInterfaces: if name in iface: return getattr(rself._sharedItem, name) raise AttributeError(name) def __setattr__(self, name, value): """ Set an attribute on the shared item. If the name of the attribute is in L{ALLOWED_ON_PROXY}, set it on this proxy instead. @param name: the name of the attribute to set @param value: the value of the attribute to set @return: None """ rself = _really(self) if name in rself._sharedInterfaces: setattr(rself._sharedItem, name, value) elif name in ALLOWED_ON_PROXY: self.__dict__[name] = value else: raise AttributeError("unsettable: "+repr(name)) ALL_IMPLEMENTED_DB = u'*' ALL_IMPLEMENTED = object() def _interfacesToNames(interfaces): """ Convert from a list of interfaces to a unicode string of names suitable for storage in the database. @param interfaces: an iterable of Interface objects. @return: a unicode string, a comma-separated list of names of interfaces. """ if interfaces is ALL_IMPLEMENTED: names = ALL_IMPLEMENTED_DB else: names = u','.join(map(qual, interfaces)) return names class Share(Item): """ A Share is a declaration that users with a given role can access a given set of functionality, as described by an Interface object. They should be created with L{shareItem} and retrieved with L{asAccessibleTo} and L{getShare}. """ schemaVersion = 2 typeName = 'sharing_share' shareID = text( doc=""" The shareID is the externally-visible identifier for this share. It is free-form text, which users may enter to access this share. Currently the only concrete use of this attribute is in HTTP[S] URLs, but in the future it will be used in menu entries. """, allowNone=False) sharedItem = reference( doc=""" The sharedItem attribute is a reference to the item which is being provided. """, allowNone=False) sharedTo = reference( doc=""" The sharedTo attribute is a reference to the Role which this item is shared with. """, allowNone=False) sharedInterfaceNames = text( doc=""" This is an internal implementation detail of the sharedInterfaces attribute. """, allowNone=False) def __init__(self, **kw): """ Create a share. Consider this interface private; use L{shareItem} instead. """ # XXX TODO: All I really want to do here is to have enforcement of # allowNone happen at the _end_ of __init__; axiom should probably do # that by default, since there are several __init__s like this which # don't really do anything scattered throughout the codebase. kw['sharedInterfaceNames'] = _interfacesToNames(kw.pop('sharedInterfaces')) super(Share, self).__init__(**kw) def sharedInterfaces(): """ This attribute is the public interface for code which wishes to discover the list of interfaces allowed by this Share. It is a list of Interface objects. """ def get(self): if not self.sharedInterfaceNames: return () if self.sharedInterfaceNames == ALL_IMPLEMENTED_DB: I = implementedBy(self.sharedItem.__class__) L = list(I) T = tuple(L) return T else: return tuple(map(namedAny, self.sharedInterfaceNames.split(u','))) def set(self, newValue): self.sharedAttributeNames = _interfacesToNames(newValue) return get, set sharedInterfaces = property( doc=sharedInterfaces.__doc__, *sharedInterfaces()) def upgradeShare1to2(oldShare): "Upgrader from Share version 1 to version 2." sharedInterfaces = [] attrs = set(oldShare.sharedAttributeNames.split(u',')) for iface in implementedBy(oldShare.sharedItem.__class__): if set(iface) == attrs or attrs == set('*'): sharedInterfaces.append(iface) newShare = oldShare.upgradeVersion('sharing_share', 1, 2, shareID=oldShare.shareID, sharedItem=oldShare.sharedItem, sharedTo=oldShare.sharedTo, sharedInterfaces=sharedInterfaces) return newShare registerUpgrader(upgradeShare1to2, 'sharing_share', 1, 2) def genShareID(store): """ Generate a new, randomized share-ID for use as the default of shareItem, if none is specified. @return: a random share-ID. @rtype: unicode. """ return unicode(os.urandom(16).encode('hex'), 'ascii') def getEveryoneRole(store): """ Get a base 'Everyone' role for this store, which is the role that every user, including the anonymous user, has. """ return store.findOrCreate(Role, externalID=u'Everyone') def getAuthenticatedRole(store): """ Get the base 'Authenticated' role for this store, which is the role that is given to every user who is explicitly identified by a non-anonymous username. """ def tx(): def addToEveryone(newAuthenticatedRole): newAuthenticatedRole.becomeMemberOf(getEveryoneRole(store)) return newAuthenticatedRole return store.findOrCreate(Role, addToEveryone, externalID=u'Authenticated') return store.transact(tx) def getPrimaryRole(store, primaryRoleName, createIfNotFound=False): """ Get Role object corresponding to an identifier name. If the role name passed is the empty string, it is assumed that the user is not authenticated, and the 'Everybody' role is primary. If the role name passed is non-empty, but has no corresponding role, the 'Authenticated' role - which is a member of 'Everybody' - is primary. Finally, a specific role can be primary if one exists for the user's given credentials, that will automatically always be a member of 'Authenticated', and by extension, of 'Everybody'. @param primaryRoleName: a unicode string identifying the role to be retrieved. This corresponds to L{Role}'s externalID attribute. @param createIfNotFound: a boolean. If True, create a role for the given primary role name if no exact match is found. The default, False, will instead retrieve the 'nearest match' role, which can be Authenticated or Everybody depending on whether the user is logged in or not. @return: a L{Role}. """ if not primaryRoleName: return getEveryoneRole(store) ff = store.findUnique(Role, Role.externalID == primaryRoleName, default=None) if ff is not None: return ff authRole = getAuthenticatedRole(store) if createIfNotFound: role = Role(store=store, externalID=primaryRoleName) role.becomeMemberOf(authRole) return role return authRole def getSelfRole(store): """ Retrieve the Role which corresponds to the user to whom the given store belongs. """ for (localpart, domain) in userbase.getAccountNames(store): return getPrimaryRole(store, u'%s@%s' % (localpart, domain), createIfNotFound=True) raise ValueError("Cannot get self-role for unnamed account.") def shareItem(sharedItem, toRole=None, toName=None, shareID=None, interfaces=ALL_IMPLEMENTED): """ Share an item with a given set of roles. This provides a way to expose items to users for later retrieval with L{getShare}. @param sharedItem: an item to be shared. @param toRole: a L{Role} instance which represents the group that has access to the given item. May not be specified if toName is also specified. @param toName: a unicode string which uniquely identifies a L{Role} in the same store as the sharedItem. @param shareID: a unicode string. If provided, specify the ID under which the shared item will be shared. @param interfaces: a list of Interface objects which specify the methods and attributes accessible to C{toRole} on C{sharedItem}. """ assert sharedItem.store is not None if shareID is None: shareID = genShareID(sharedItem.store) if toRole is None: if toName is not None: toRole = getPrimaryRole(sharedItem.store, toName, True) else: toRole = getEveryoneRole(sharedItem.store) else: assert toName is None assert sharedItem.store is toRole.store return Share(store=sharedItem.store, shareID=shareID, sharedItem=sharedItem, sharedTo=toRole, sharedInterfaces=interfaces) def getShare(store, role, shareID): """ Retrieve the accessible facet of an Item previously shared with L{shareItem}. @param store: an axiom store (XXX must be the same as role.store) @param role: a L{Role}, the primary role for a user attempting to retrieve the given item. @return: a L{SharedProxy}. This is a wrapper around the shared item which only exposes those interfaces explicitly allowed for the given role. @raise: L{NoSuchShare} if there is no item shared to the given role for the given shareID. """ shares = list( store.query(Share, AND(Share.shareID == shareID, Share.sharedTo.oneOf(role.allRoles())))) interfaces = [] for share in shares: interfaces += share.sharedInterfaces if shares: return SharedProxy(shares[0].sharedItem, interfaces, shareID) raise NoSuchShare() def asAccessibleTo(role, query): """ @param role: The role to retrieve L{SharedProxy}s for. @param query: An Axiom query describing the Items to retrieve, which this role can access. """ if query.comparison is not None: #XXX fix axiom to not break if an AND branch is None? comparison = AND(Share.sharedItem == query.tableClass.storeID, Share.sharedTo.oneOf(role.allRoles()), query.comparison) else: comparison = AND(Share.sharedItem == query.tableClass.storeID, Share.sharedTo.oneOf(role.allRoles())) lastItem = None lastID = None interfaceSpec = None yielded = False seen = set() for resultingShare in (query.store.query( Share, comparison, sort=query.sort, limit=query.limit )): yielded = False thisItem = resultingShare.sharedItem thisID = resultingShare.shareID if (lastItem == thisItem) and (thisID == lastID): interfaceSpec += resultingShare.sharedInterfaces else: if lastItem is not None: yielded = True yield SharedProxy(lastItem, interfaceSpec, lastID) interfaceSpec = resultingShare.sharedInterfaces[:] lastItem = thisItem lastID = thisID if lastItem is not None: yield SharedProxy(lastItem, interfaceSpec, lastID) def itemFromProxy(obj): """ @param obj: a L{SharedProxy} instance @return: the underlying Item instance of the given L{SharedProxy}, with all of its methods and attributes exposed. """ return object.__getattribute__(obj, '_sharedItem') def unShare(sharedItem): """ Remove all instances of this item from public or shared view. """ sharedItem.store.query(Share, Share.sharedItem == sharedItem).deleteFromStore() def randomEarlyShared(store, role): """ If there are no explicitly-published public index pages to display, find a shared item to present to the user as first. """ for r in role.allRoles(): share = store.findFirst(Share, Share.sharedTo == r, sort=Share.storeID.ascending) if share is not None: return share.sharedItem raise NoSuchShare("Why, that user hasn't shared anything at all!")