Source code for assembl.models.permissions

"""All classes relative to permissions."""
from future import standard_library
standard_library.install_aliases()
from collections import defaultdict
import logging

from sqlalchemy import (
    Boolean,
    Column,
    String,
    ForeignKey,
    Integer,
    event,
    Index,
)
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import (
    relationship, backref)
from pyramid.httpexceptions import HTTPBadRequest
from rdflib import URIRef

from ..lib import config
from ..lib.abc import abstractclassmethod
from ..lib.sqla import CrudOperation
from . import Base, DiscussionBoundBase, PrivateObjectMixin, NamedClassMixin
from ..auth import *
from ..semantic.namespaces import (
    SIOC, ASSEMBL, QUADNAMES)
from ..semantic.virtuoso_mapping import (
    QuadMapPatternS, USER_SECTION, AppQuadStorageManager)
from .auth import User, AgentProfile

log = logging.getLogger(__name__)


[docs]class Role(NamedClassMixin, Base): """A role that a user may have in a discussion""" __tablename__ = 'role' id = Column(Integer, primary_key=True) name = Column(String(20), nullable=False) @classmethod def get_naming_column_name(cls): return "name" @classmethod def populate_db(cls, db=None): db = db or cls.default_db() db.execute("lock table %s in exclusive mode" % cls.__table__.name) roles = {r[0] for r in db.query(cls.name).all()} for role in SYSTEM_ROLES - roles: db.add(cls(name=role))
[docs]class UserRole(Base, PrivateObjectMixin): """roles that a user has globally (eg admin.)""" __tablename__ = 'user_role' rdf_sections = (USER_SECTION,) rdf_class = SIOC.Role id = Column(Integer, primary_key=True) profile_id = Column( Integer, ForeignKey('agent_profile.id', ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True, info={'rdf': QuadMapPatternS( None, SIOC.function_of, AgentProfile.agent_as_account_iri.apply(None))}) user = relationship( AgentProfile, backref=backref("roles", cascade="all, delete-orphan")) role_id = Column( Integer, ForeignKey('role.id', ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) role = relationship(Role, lazy="joined") def get_user_uri(self): return AgentProfile.uri_generic(self.profile_id) @property def role_name(self): return self.role.name @role_name.setter def role_name(self, name): self.role = Role.getByName(name)
[docs] def container_url(self): return "/data/User/%d/roles" % (self.user_id)
[docs] def is_owner(self, user_id): return self.profile_id == user_id
[docs] @classmethod def restrict_to_owners_condition(cls, query, user_id, alias=None, alias_maker=None): if not alias: if alias_maker: alias = alias_maker.alias_from_class(cls) else: alias = cls return (query, alias.profile_id == user_id)
def get_default_parent_context(self, request=None, user_id=None): return self.user.get_collection_context( 'roles', self.user.get_class_context(), request, user_id)
[docs] @classmethod def special_quad_patterns(cls, alias_maker, discussion_id): role_alias = alias_maker.alias_from_relns(cls.role) return [ QuadMapPatternS(cls.iri_class().apply(cls.id), SIOC.name, role_alias.name, name=QUADNAMES.class_UserRole_rolename, sections=(USER_SECTION,)), QuadMapPatternS(AgentProfile.agent_as_account_iri.apply(cls.user_id), SIOC.has_function, cls.iri_class().apply(cls.id), name=QUADNAMES.class_UserRole_global, sections=(USER_SECTION,)), # Note: The IRIs need to distinguish UserRole from LocalUserRole QuadMapPatternS(cls.iri_class().apply(cls.id), SIOC.has_scope, URIRef(AppQuadStorageManager.local_uri()), name=QUADNAMES.class_UserRole_globalscope, sections=(USER_SECTION,)), ]
@event.listens_for(UserRole, 'after_insert', propagate=True) @event.listens_for(UserRole, 'after_delete', propagate=True) def send_user_to_socket_for_user_role(mapper, connection, target): user = target.user if not target.user: user = User.get(target.user_id) user.send_to_changes(connection, CrudOperation.UPDATE, view_def="private") user.send_to_changes(connection, CrudOperation.UPDATE)
[docs]class AbstractLocalUserRole(DiscussionBoundBase, PrivateObjectMixin): __abstract__ = True @declared_attr def id(self): return Column(Integer, primary_key=True) @declared_attr def profile_id(self): return Column(Integer, ForeignKey( 'agent_profile.id', ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True, info={'rdf': QuadMapPatternS( None, SIOC.function_of, AgentProfile.agent_as_account_iri.apply(None))}) @declared_attr def role_id(self): return Column(Integer, ForeignKey( 'role.id', ondelete='CASCADE', onupdate='CASCADE'), index=True, nullable=False) @declared_attr def role(self): return relationship(Role, lazy="joined") @property def role_name(self): return self.role.name @role_name.setter def role_name(self, name): self.role = Role.getByName(name) def get_user_uri(self): return AgentProfile.uri_generic(self.profile_id) @abstractclassmethod def filter_on_instance(cls, instance, query): return query
[docs] def is_owner(self, user_id): return self.profile_id == user_id
[docs] @classmethod def restrict_to_owners_condition(cls, query, user_id, alias=None, alias_maker=None): if not alias: if alias_maker: alias = alias_maker.alias_from_class(cls) else: alias = cls return (query, alias.profile_id == user_id)
[docs]class LocalUserRole(AbstractLocalUserRole): """The role that a user has in the context of a discussion""" __tablename__ = 'local_user_role' rdf_sections = (USER_SECTION,) rdf_class = SIOC.Role user = relationship(AgentProfile, backref=backref("local_roles", cascade="all, delete-orphan")) discussion_id = Column(Integer, ForeignKey( 'discussion.id', ondelete='CASCADE'), nullable=False, index=True, info={'rdf': QuadMapPatternS(None, SIOC.has_scope)}) discussion = relationship( 'Discussion', backref=backref( "local_user_roles", cascade="all, delete-orphan"), info={'rdf': QuadMapPatternS(None, ASSEMBL.in_conversation)}) requested = Column(Boolean, server_default='0', default=False) __table_args__ = ( Index('ix_local_user_role_user_discussion', 'profile_id', 'discussion_id'),)
[docs] def get_discussion_id(self): return self.discussion_id or self.discussion.id
[docs] def container_url(self): return "/data/Discussion/%d/all_users/%d/local_roles" % ( self.discussion_id, self.profile_id)
def get_default_parent_context(self, request=None, user_id=None): return self.user.get_collection_context('roles', request=request, user_id=user_id)
[docs] @classmethod def get_discussion_conditions(cls, discussion_id, alias_maker=None): return (cls.discussion_id == discussion_id,)
@classmethod def filter_on_instance(cls, instance, query): assert instance.__class__.__name__ == 'Discussion' return query.filter_by(discussion_id=instance.id)
[docs] def unique_query(self): query, _ = super(LocalUserRole, self).unique_query() profile_id = self.profile_id or self.user.id role_id = self.role_id or self.role.id return query.filter_by( profile_id=profile_id, role_id=role_id), True
def _do_update_from_json( self, json, parse_def, ctx, duplicate_handling=None, object_importer=None): user_id = ctx.get_user_id() json_user_id = json.get('user', None) if json_user_id is None: json_user_id = user_id else: json_user_id = AgentProfile.get_database_id(json_user_id) # Do not allow changing user if self.profile_id is not None and json_user_id != self.profile_id: raise HTTPBadRequest() self.profile_id = json_user_id role_name = json.get("role", None) if not (role_name or self.role_id): role_name = R_PARTICIPANT if role_name: role = self.db.query(Role).filter_by(name=role_name).first() if not role: raise HTTPBadRequest("Invalid role name:"+role_name) self.role = role json_discussion_id = json.get('discussion', None) if json_discussion_id: from .discussion import Discussion json_discussion_id = Discussion.get_database_id(json_discussion_id) # Do not allow change of discussion if self.discussion_id is not None \ and json_discussion_id != self.discussion_id: raise HTTPBadRequest() self.discussion_id = json_discussion_id else: if not self.discussion_id: raise HTTPBadRequest() return self
[docs] @classmethod def base_conditions(cls, alias=None, alias_maker=None): cls = alias or cls return (cls.requested == False,)
[docs] @classmethod def special_quad_patterns(cls, alias_maker, discussion_id): role_alias = alias_maker.alias_from_relns(cls.role) return [ QuadMapPatternS(AgentProfile.agent_as_account_iri.apply(cls.profile_id), SIOC.has_function, cls.iri_class().apply(cls.id), name=QUADNAMES.class_LocalUserRole_global, conditions=(cls.requested == False,), sections=(USER_SECTION,)), QuadMapPatternS(cls.iri_class().apply(cls.id), SIOC.name, role_alias.name, conditions=(cls.requested == False,), sections=(USER_SECTION,), name=QUADNAMES.class_LocalUserRole_rolename)]
crud_permissions = CrudPermissions( P_SELF_REGISTER, P_READ, P_ADMIN_DISC, P_ADMIN_DISC, P_SELF_REGISTER, P_SELF_REGISTER)
[docs] @classmethod def user_can_cls(cls, user_id, operation, permissions): # bypass... more checks are required upstream, # see assembl.views.api2.auth.add_local_role if operation == CrudPermissions.CREATE \ and P_SELF_REGISTER_REQUEST in permissions: return True return super(LocalUserRole, cls).user_can_cls( user_id, operation, permissions)
@event.listens_for(LocalUserRole, 'after_delete', propagate=True) @event.listens_for(LocalUserRole, 'after_insert', propagate=True) def send_user_to_socket_for_local_user_role( mapper, connection, target): user = target.user if not target.user: user = User.get(target.profile_id) user.send_to_changes(connection, CrudOperation.UPDATE, target.discussion_id) user.send_to_changes( connection, CrudOperation.UPDATE, target.discussion_id, "private")
[docs]class Permission(NamedClassMixin, Base): """A permission that a user may have""" __tablename__ = 'permission' id = Column(Integer, primary_key=True) name = Column(String(), nullable=False) @classmethod def populate_db(cls, db=None): db = db or cls.default_db() db.execute("lock table %s in exclusive mode" % cls.__table__.name) perms = {p[0] for p in db.query(cls.name).all()} for perm in ASSEMBL_PERMISSIONS - perms: db.add(cls(name=perm)) @classmethod def get_naming_column_name(cls): return "name"
[docs]class DiscussionPermission(DiscussionBoundBase): """Which permissions are given to which roles for a given discussion.""" __tablename__ = 'discussion_permission' id = Column(Integer, primary_key=True) discussion_id = Column(Integer, ForeignKey( 'discussion.id', ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) discussion = relationship( 'Discussion', backref=backref( "acls", cascade="all, delete-orphan"), info={'rdf': QuadMapPatternS(None, ASSEMBL.in_conversation)}) role_id = Column(Integer, ForeignKey( Role.id, ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) role = relationship(Role, lazy="joined") permission_id = Column(Integer, ForeignKey( 'permission.id', ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) permission = relationship(Permission, lazy="joined") @property def role_name(self): return self.role.name @role_name.setter def role_name(self, name): role = Role.getByName(name) assert role self.role = role @property def permission_name(self): return self.permission.name @permission_name.setter def permission_name(self, name): permission = Permission.getByName(name) assert permission self.permission = permission
[docs] def get_discussion_id(self): return self.discussion_id or self.discussion.id
[docs] @classmethod def get_discussion_conditions(cls, discussion_id, alias_maker=None): return (cls.discussion_id == discussion_id, )
def create_default_permissions(discussion, public=True): session = discussion.db permissions = {p.name: p for p in session.query(Permission).all()} roles = {r.name: r for r in session.query(Role).all()} default_sets = [discussion.preferences['default_permissions']] if public: default_sets.append(discussion.preferences['default_permissions_public']) existing = set() for defaults in default_sets: for role_name, permission_names in defaults.items(): role = roles.get(role_name, None) assert role, "Unknown role: " + role_name for permission_name in permission_names: if (role_name, permission_name) in existing: continue permission = permissions.get(permission_name, None) assert permission, "Unknown permission: " + permission_name session.add(DiscussionPermission( discussion=discussion, role=role, permission=permission)) existing.add((role_name, permission_name))
[docs]class UserTemplate(DiscussionBoundBase, User): "A fake user with default permissions and Notification Subscriptions." __tablename__ = "user_template" __mapper_args__ = { 'polymorphic_identity': 'user_template' } id = Column( Integer, ForeignKey('user.id', ondelete='CASCADE', onupdate='CASCADE'), primary_key=True ) discussion_id = Column(Integer, ForeignKey( "discussion.id", ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) discussion = relationship( "Discussion", backref=backref( "user_templates", cascade="all, delete-orphan"), info={'rdf': QuadMapPatternS(None, ASSEMBL.in_conversation)}) role_id = Column(Integer, ForeignKey( Role.id, ondelete='CASCADE', onupdate='CASCADE'), nullable=False, index=True) for_role = relationship(Role) # Create an index for (discussion, role)?
[docs] def get_discussion_id(self): return self.discussion_id or self.discussion.id
[docs] @classmethod def get_discussion_conditions(cls, discussion_id, alias_maker=None): return (cls.discussion_id == discussion_id,)
[docs] @classmethod def get_applicable_notification_subscriptions_classes(cls): """ The classes of notifications subscriptions that make sense to put in a template user. Right now, that is all concrete classes that are global to the discussion. """ from ..lib.utils import get_concrete_subclasses_recursive from ..models import NotificationSubscriptionGlobal return get_concrete_subclasses_recursive(NotificationSubscriptionGlobal)
[docs] def get_notification_subscriptions(self): return self.get_notification_subscriptions_and_changed()[0]
[docs] def get_notification_subscriptions_and_changed(self, on_thread=True): """the notification subscriptions for this template. Materializes applicable subscriptions..""" from .notification import ( NotificationSubscription, NotificationSubscriptionStatus, NotificationCreationOrigin, NotificationSubscriptionGlobal) needed_classes = set( self.get_applicable_notification_subscriptions_classes()) # We need to materialize missing NotificationSubscriptions, # But have duplication issues, probably due to calls on multiple # threads. So iterate until it works. query = self.db.query(NotificationSubscription).filter_by( discussion_id=self.discussion_id, user_id=self.id) changed = False discussion = self.discussion my_id = self.id role_name = self.for_role.name.split(':')[-1] # TODO: Fill from config. subscribed = defaultdict(bool) default_config = config.get_config().get( ".".join(("subscriptions", role_name, "default")), "FOLLOW_SYNTHESES") for role in default_config.split('\n'): subscribed[role.strip()] = True def calculate_missing(): my_subscriptions = query.all() if self.id else [] by_class = defaultdict(list) for sub in my_subscriptions: by_class[sub.__class__].append(sub) my_subscriptions_classes = set(by_class.keys()) # We should have at most one subscription of a class, but we've had more. # Delete excess subscriptions for cl, subs in by_class.items(): if not issubclass(cl, NotificationSubscriptionGlobal): # Should not happen, all global on template continue if len(subs) > 1: log.error("There were many subscriptions of class %s" % (cl)) subs.sort(key=lambda sub: sub.id) first_sub = subs[0] for sub in subs[1:]: first_sub.merge(sub) sub.delete() by_class[cl] = subs[0] my_subscriptions = list(by_class.values()) missing = needed_classes - my_subscriptions_classes if my_subscriptions_classes - needed_classes: log.error("Unknown subscription class: " + repr( my_subscriptions_classes - needed_classes)) discussion_kwarg = (dict(discussion_id=discussion.id) if discussion.id else dict(discussion=discussion)) return [ cls( user_id=my_id, creation_origin=NotificationCreationOrigin.DISCUSSION_DEFAULT, status=(NotificationSubscriptionStatus.ACTIVE if subscribed[cls.__mapper__.polymorphic_identity.name] else NotificationSubscriptionStatus.INACTIVE_DFT), **discussion_kwarg) for cls in missing ] if on_thread: changed |= self.locked_object_creation( calculate_missing, num_attempts=10) else: for ob in calculate_missing(): self.db.add(ob) changed = True if changed: self.db.expire(self, ['notification_subscriptions']) return self.notification_subscriptions, changed
Index("user_template", "discussion_id", "role_id")