diff --git a/lib/galaxy/app.py b/lib/galaxy/app.py index 0c4ea728395..1964945012e 100644 --- a/lib/galaxy/app.py +++ b/lib/galaxy/app.py @@ -4,6 +4,7 @@ from galaxy import config, jobs, util, tools, web import galaxy.model import galaxy.model.mapping import galaxy.datatypes.registry +import galaxy.security class UniverseApplication( object ): """Encapsulates the state of a Universe application""" @@ -30,6 +31,8 @@ class UniverseApplication( object ): self.toolbox = tools.ToolBox( self.config.tool_config, self.config.tool_path, self ) #Load datatype converters self.datatypes_registry.load_datatype_converters( self.toolbox ) + #Load security policy + self.security_agent = self.model.security_agent # Start the job queue job_dispatcher = jobs.DefaultJobDispatcher( self ) self.job_queue = jobs.JobQueue( self, job_dispatcher ) diff --git a/lib/galaxy/datatypes/images.py b/lib/galaxy/datatypes/images.py index 90d44ab47f4..7c58d38f632 100644 --- a/lib/galaxy/datatypes/images.py +++ b/lib/galaxy/datatypes/images.py @@ -110,7 +110,7 @@ class Gmaj( data.Data ): "nobutton": "false", "urlpause" :"100", "debug": "false", - "posturl": "history_add_to?%s" % urlencode( { 'history_id': dataset.history_id, 'ext': 'maf', 'name': 'GMAJ Output on data %s' % dataset.hid, 'info': 'Added by GMAJ', 'dbkey': dataset.dbkey } ) + "posturl": "history_add_to?%s" % urlencode( { 'history_id': dataset.history_id, 'ext': 'maf', 'name': 'GMAJ Output on data %s' % dataset.hid, 'info': 'Added by GMAJ', 'dbkey': dataset.dbkey, 'copy_access_from': dataset.id } ) } class_name = "edu.psu.bx.gmaj.MajApplet.class" archive = "/static/gmaj/gmaj.jar" @@ -180,7 +180,7 @@ class Laj( data.Text ): "alignfile1": "display?id=%s" % dataset.id, "buttonlabel": "Launch LAJ", "title": "LAJ in Galaxy", - "posturl": "history_add_to?%s" % urlencode( { 'history_id': dataset.history_id, 'ext': 'lav', 'name': 'LAJ Output', 'info': 'Added by LAJ', 'dbkey': dataset.dbkey } ), + "posturl": "history_add_to?%s" % urlencode( { 'history_id': dataset.history_id, 'ext': 'lav', 'name': 'LAJ Output', 'info': 'Added by LAJ', 'dbkey': dataset.dbkey, 'copy_access_from': dataset.id } ), "noseq": "true" } class_name = "edu.psu.cse.bio.laj.LajApplet.class" diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 7d08ccb2e46..cadd7a78ef0 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -13,6 +13,7 @@ from galaxy import util import tempfile import galaxy.datatypes.registry from galaxy.datatypes.metadata import MetadataCollection +from galaxy.security import RBACAgent import logging log = logging.getLogger( __name__ ) @@ -33,13 +34,14 @@ class User( object ): self.external = False # Relationships self.histories = [] + def set_password_cleartext( self, cleartext ): """Set 'self.password' to the digest of 'cleartext'.""" self.password = sha.new( cleartext ).hexdigest() def check_password( self, cleartext ): """Check if 'cleartext' matches 'self.password' when hashed.""" return self.password == sha.new( cleartext ).hexdigest() - + class Job( object ): """ A job represents a request to run a tool given input datasets, tool @@ -101,10 +103,174 @@ class JobToOutputDatasetAssociation( object ): self.name = name self.dataset = dataset +class GroupDatasetAssociation( object ): + dataset_actions = RBACAgent.permitted_actions.dataset_actions + group_actions = RBACAgent.permitted_actions.group_actions + def __init__( self, group, dataset, permitted_actions=[] ): + if isinstance( group, GroupDatasetAssociation ) or \ + isinstance( group, DefaultUserGroupAssociation ) or \ + isinstance( group, DefaultHistoryGroupAssociation ): + group = group.group + self.group = group + if isinstance( dataset, HistoryDatasetAssociation ): + dataset = dataset.dataset + self.dataset = dataset + self.permitted_actions = permitted_actions + def add_permitted_action( self, action ): + if action not in self.permitted_actions: + return self.permitted_actions.append( action ) + raise 'action (%s) already exists in permitted actions list (%s: %s).' % ( action, str( self.id ), str( self.permitted_actions ) ) + def remove_permitted_action( self, action ): + return self.permitted_actions.remove( action ) + +class Group( object ): + public_id = None + permitted_actions = GroupDatasetAssociation.group_actions + def __init__( self, name = None, priority = 0 ): + self.name = name + self.priority = priority + @classmethod + def get_public_group( cls ): + # TODO, Nate: Make sure this method is functionally correct. + return Group.get( cls.public_id ) + @classmethod + def set_public_group( cls, group ): + # TODO, Nate: Make sure this method is functionally correct. + #we store the id instead of the object, because of alchemy sessions + if isinstance( group, Group ): + group = group.id + cls.public_id = group + @classmethod + def guess_public_group( cls ): + # TODO, Nate: Make sure this method is functionally correct. + #retrieve from database and store public group id, assume first created group is public + cls.set_public_group( Group.select_by( name = 'public' ) ) + +class UserGroupAssociation( object ): + def __init__( self, user, group ): + self.user = user + self.group = group + +class DefaultUserGroupAssociation( object ): + def __init__( self, user, group, permitted_actions ): + if isinstance( group, GroupDatasetAssociation ) or \ + isinstance( group, DefaultUserGroupAssociation ) or \ + isinstance( group, DefaultHistoryGroupAssociation ): + group = group.group + self.user = user + self.group = group + self.permitted_actions = permitted_actions + +class DefaultHistoryGroupAssociation( object ): + def __init__( self, history, group, permitted_actions ): + if isinstance( group, GroupDatasetAssociation ) or \ + isinstance( group, DefaultUserGroupAssociation ) or \ + isinstance( group, DefaultHistoryGroupAssociation ): + group = group.group + self.history = history + self.group = group + self.permitted_actions = permitted_actions + +class Dataset( object ): + states = Bunch( NEW = 'new', + QUEUED = 'queued', + RUNNING = 'running', + OK = 'ok', + EMPTY = 'empty', + ERROR = 'error', + DISCARDED = 'discarded' ) + permitted_actions = GroupDatasetAssociation.dataset_actions + file_path = "/tmp/" + engine = None + def __init__( self, id=None, state=None, external_filename=None, extra_files_path=None, file_size=None, purgable=True ): + self.id = id + self.state = state + self.deleted = False + self.purged = False + self.purgable = purgable + self.external_filename = external_filename + self._extra_files_path = extra_files_path + self.file_size = file_size + + def get_file_name( self ): + if not self.external_filename: + assert self.id is not None, "ID must be set before filename used (commit the object)" + # First try filename directly under file_path + filename = os.path.join( self.file_path, "dataset_%d.dat" % self.id ) + # Only use that filename if it already exists (backward compatibility), + # otherwise construct hashed path + if not os.path.exists( filename ): + dir = os.path.join( self.file_path, *directory_hash_id( self.id ) ) + # Create directory if it does not exist + try: + os.makedirs( dir ) + except OSError, e: + # File Exists is okay, otherwise reraise + if e.errno != errno.EEXIST: + raise + # Return filename inside hashed directory + return os.path.abspath( os.path.join( dir, "dataset_%d.dat" % self.id ) ) + else: + filename = self.external_filename + # Make filename absolute + return os.path.abspath( filename ) + + def set_file_name ( self, filename ): + if not filename: + self.external_filename = None + else: + self.external_filename = filename + + file_name = property( get_file_name, set_file_name ) + + @property + def extra_files_path( self ): + if self._extra_files_path: + path = self._extra_files_path + else: + path = os.path.join( self.file_path, "dataset_%d_files" % self.id ) + #only use path directly under self.file_path if it exists + if not os.path.exists( path ): + path = os.path.join( os.path.join( self.file_path, *directory_hash_id( self.id ) ), "dataset_%d_files" % self.id ) + # Make path absolute + return os.path.abspath( path ) + + def get_size( self ): + """Returns the size of the data on disk""" + if self.file_size: + return self.file_size + else: + try: + return os.path.getsize( self.file_name ) + except OSError: + return 0 + def set_size( self ): + """Returns the size of the data on disk""" + try: + self.file_size = os.path.getsize( self.file_name ) + except OSError: + self.file_size = 0 + def has_data( self ): + """Detects whether there is any data""" + return self.get_size() > 0 + def mark_deleted( self, include_children=True ): + self.deleted = True + + # FIXME: sqlalchemy will replace this + def _delete(self): + """Remove the file that corresponds to this data""" + try: + os.remove(self.data.file_name) + except OSError, e: + log.critical('%s delete error %s' % (self.__class__.__name__, e)) + class HistoryDatasetAssociation( object ): + states = Dataset.states + permitted_actions = Dataset.permitted_actions def __init__( self, id=None, hid=None, name=None, info=None, blurb=None, peek=None, extension=None, dbkey=None, metadata=None, history=None, dataset=None, deleted=False, designation=None, - parent_id=None, copied_from_history_dataset_association = None, validation_errors=None, visible=True, create_dataset = False ): + parent_id=None, copied_from_history_dataset_association = None, validation_errors=None, + visible=True, create_dataset = False ): self.name = name or "Unnamed dataset" self.id = id self.hid = hid @@ -131,10 +297,6 @@ class HistoryDatasetAssociation( object ): def ext( self ): return self.extension - @property - def states( self ): - return self.dataset.states - def get_dataset_state( self ): return self.dataset.state def set_dataset_state ( self, state ): @@ -252,7 +414,8 @@ class HistoryDatasetAssociation( object ): def get_converter_types(self): return self.datatype.get_converter_types( self, datatypes_registry) - def copy( self, copy_children = False, parent_id = None ): + def copy( self, copy_children = False, parent_id = None, target_user = None ): + if target_user is None: target_user = self.user des = HistoryDatasetAssociation( hid=self.hid, name=self.name, info=self.info, blurb=self.blurb, peek=self.peek, extension=self.extension, dbkey=self.dbkey, metadata=self._metadata, dataset = self.dataset, visible=self.visible, deleted=self.deleted, parent_id=parent_id, copied_from_history_dataset_association = self ) des.flush() if copy_children: @@ -275,7 +438,6 @@ class HistoryDatasetAssociation( object ): child.mark_deleted() - class History( object ): def __init__( self, id=None, name=None, user=None ): self.id = id @@ -326,18 +488,21 @@ class History( object ): self.genome_build = genome_build self.datasets.append( dataset ) - def copy(self): - des = History() + def copy( self, target_user = None ): + if not target_user: + target_user = self.user + des = History( user = target_user ) des.flush() des.name = self.name - des.user_id = self.user_id for data in self.datasets: - new_data = data.copy( copy_children = True ) + new_data = data.copy( copy_children = True, target_user = target_user ) des.add_dataset( new_data ) new_data.flush() des.hid_counter = self.hid_counter des.flush() return des + + # class Query( object ): # def __init__( self, name=None, state=None, tool_parameters=None, history=None ): @@ -348,100 +513,6 @@ class History( object ): # self.history = history # self.datasets = [] -class Dataset( object ): - states = Bunch( NEW = 'new', - QUEUED = 'queued', - RUNNING = 'running', - OK = 'ok', - EMPTY = 'empty', - ERROR = 'error', - DISCARDED = 'discarded' ) - file_path = "/tmp/" - engine = None - def __init__( self, id=None, state=None, external_filename=None, extra_files_path=None, file_size=None, purgable=True ): - self.id = id - self.state = state - self.deleted = False - self.purged = False - self.purgable = purgable - self.external_filename = external_filename - self._extra_files_path = extra_files_path - self.file_size = file_size - - def get_file_name( self ): - if not self.external_filename: - assert self.id is not None, "ID must be set before filename used (commit the object)" - # First try filename directly under file_path - filename = os.path.join( self.file_path, "dataset_%d.dat" % self.id ) - # Only use that filename if it already exists (backward compatibility), - # otherwise construct hashed path - if not os.path.exists( filename ): - dir = os.path.join( self.file_path, *directory_hash_id( self.id ) ) - # Create directory if it does not exist - try: - os.makedirs( dir ) - except OSError, e: - # File Exists is okay, otherwise reraise - if e.errno != errno.EEXIST: - raise - # Return filename inside hashed directory - return os.path.abspath( os.path.join( dir, "dataset_%d.dat" % self.id ) ) - else: - filename = self.external_filename - # Make filename absolute - return os.path.abspath( filename ) - - def set_file_name ( self, filename ): - if not filename: - self.external_filename = None - else: - self.external_filename = filename - - file_name = property( get_file_name, set_file_name ) - - @property - def extra_files_path( self ): - if self._extra_files_path: - path = self._extra_files_path - else: - path = os.path.join( self.file_path, "dataset_%d_files" % self.id ) - #only use path directly under self.file_path if it exists - if not os.path.exists( path ): - path = os.path.join( os.path.join( self.file_path, *directory_hash_id( self.id ) ), "dataset_%d_files" % self.id ) - # Make path absolute - return os.path.abspath( path ) - - def get_size( self ): - """Returns the size of the data on disk""" - if self.file_size: - return self.file_size - else: - try: - return os.path.getsize( self.file_name ) - except OSError: - return 0 - def set_size( self ): - """Returns the size of the data on disk""" - try: - self.file_size = os.path.getsize( self.file_name ) - except OSError: - self.file_size = 0 - def has_data( self ): - """Detects whether there is any data""" - return self.get_size() > 0 - def mark_deleted( self, include_children=True ): - self.deleted = True - - # FIXME: sqlalchemy will replace this - def _delete(self): - """Remove the file that corresponds to this data""" - try: - os.remove(self.data.file_name) - except OSError, e: - log.critical('%s delete error %s' % (self.__class__.__name__, e)) - -class Old_Dataset( Dataset ): - pass class ValidationError( object ): def __init__( self, message=None, err_type=None, attributes=None ): diff --git a/lib/galaxy/model/mapping.py b/lib/galaxy/model/mapping.py index cdc8419c439..c99899d4e5a 100644 --- a/lib/galaxy/model/mapping.py +++ b/lib/galaxy/model/mapping.py @@ -19,6 +19,7 @@ from sqlalchemy import * from galaxy.model import * from galaxy.model.custom_types import * from galaxy.util.bunch import Bunch +from galaxy.security import GalaxyRBACAgent metadata = DynamicMetaData( threadlocal=False ) context = SessionContext( create_session ) @@ -114,6 +115,47 @@ ValidationError.table = Table( "validation_error", metadata, Column( "err_type", TrimmedString( 64 ) ), Column( "attributes", TEXT ) ) +Group.table = Table( "galaxy_group", metadata, + Column( "id", Integer, primary_key=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ), + Column( "name", TEXT ), + Column( "priority", Integer ) ) + +UserGroupAssociation.table = Table( "user_group_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ), + Column( "group_id", Integer, ForeignKey( "galaxy_group.id" ), index=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ) ) + +GroupDatasetAssociation.table = Table( "group_dataset_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "group_id", Integer, ForeignKey( "galaxy_group.id" ), index=True ), + Column( "dataset_id", Integer, ForeignKey( "dataset.id" ), index=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ), + Column( "permitted_actions", JSONType(), default=[] ) ) + +# TODO, Nate: Need to better understand what these Default tables are for and add appropriate +# comments here to clarify them. Need to ensure that they should include the permitted_actions +# columns, and if so, that they are correctly populated. +DefaultUserGroupAssociation.table = Table( "default_user_group_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "group_id", Integer, ForeignKey( "galaxy_group.id" ), index=True ), + Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ), + Column( "permitted_actions", JSONType(), default=[] ) ) + +DefaultHistoryGroupAssociation.table = Table( "default_history_group_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "group_id", Integer, ForeignKey( "galaxy_group.id" ), index=True ), + Column( "history_id", Integer, ForeignKey( "history.id" ), index=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ), + Column( "permitted_actions", JSONType(), default=[] ) ) + Job.table = Table( "job", metadata, Column( "id", Integer, primary_key=True ), Column( "create_time", DateTime, default=now ), @@ -298,6 +340,30 @@ assign_mapper( context, User, User.table, collection_class=ordering_list( 'order_index' ) ) ) ) +assign_mapper( context, Group, Group.table, + properties=dict( users=relation( UserGroupAssociation ), + datasets=relation( GroupDatasetAssociation ) ) ) + +assign_mapper( context, UserGroupAssociation, UserGroupAssociation.table, + properties=dict( user=relation( User, backref = "groups" ), + group=relation( Group, backref = "users" ) ) ) + + +# TODO, Nate: Need to make sure we have optimal performance - may need more mappers... +# if we have a user and a list of datasets, what is the fastest +# way to ask whether the user has a certain action on all of them. +assign_mapper( context, GroupDatasetAssociation, GroupDatasetAssociation.table, + properties=dict( dataset=relation( Dataset, backref = "groups" ), + group=relation( Group, backref = "datasets" ) ) ) + +assign_mapper( context, DefaultUserGroupAssociation, DefaultUserGroupAssociation.table, + properties=dict( user=relation( User, backref = "default_groups" ), + group=relation( Group ) ) ) + +assign_mapper( context, DefaultHistoryGroupAssociation, DefaultHistoryGroupAssociation.table, + properties=dict( history=relation( History, backref = "default_groups" ), + group=relation( Group ) ) ) + assign_mapper( context, JobToInputDatasetAssociation, JobToInputDatasetAssociation.table, properties=dict( job=relation( Job ), dataset=relation( HistoryDatasetAssociation ) ) ) @@ -411,6 +477,41 @@ def init( file_path, url, engine_options={}, create_tables=False ): result.flush = lambda *args, **kwargs: context.current.flush( *args, **kwargs ) result.context = context result.create_tables = create_tables + #load local galaxy security policy + result.security_agent = GalaxyRBACAgent( result ) + # TODO, Nate: The following may not work for our Galaxy instances because there are too + # many rows that need updating ( I think ) even though we have eliminated all of the + # Role stuff. Maybe we can test this to see how long it takes for about 1000 datasets. + # If we decide to use this approach rather than SQL commands to populate the tables, + # then this needs to be thoroughly tested to ensure the data is populated as expected + # (i.e., make sure naything that is public gets the public security settings, etc). + # + # Set up default table entries here, only exist for group access because + # permitted actions are exclusively restricted to the association between a group + # and a dataset + if result.Group.count() == 0: + log.warning( "There were no groups located, setting up default (public) group." ) + # Create public group + public_group = result.security_agent.create_group( name = 'public' ) + # Store public group id + result.security_agent.set_public_group( public_group ) + # Loop through all histories and set up rbac on users, histories and datasets + for history in result.History.select( result.History.table.c.purged == False ): + if history.user: + if not history.user.default_groups: + result.security_agent.setup_new_user( history.user ) + history.user.flush() + else: + result.security_agent.history_set_default_access( history, dataset=True ) + history.flush() + # Add all datasets which aren't in a history to the public group + orphans = result.Dataset.get_by( history_id = None ) + if orphans: + for dataset in orphans: + result.security_agent.set_dataset_groups( dataset, [ public_group ] ) + else: + result.security_agent.guess_public_group() + log.debug( "Public Group identified as id = %s." % ( Group.public_id ) ) return result def get_suite(): diff --git a/lib/galaxy/security/__init__.py b/lib/galaxy/security/__init__.py new file mode 100644 index 00000000000..6582da8c368 --- /dev/null +++ b/lib/galaxy/security/__init__.py @@ -0,0 +1,231 @@ +""" +Utility functions used systemwide. + +""" +import logging +from galaxy.util.bunch import Bunch + +log = logging.getLogger(__name__) + +# TODO, Nate: Think about whether the following permitted actions are appropriate for the dataset and +# group objects. What should be the default "public" permitted actions? Make sure that the public group +# and public datasets are set with the correct permitted actions. Also make sure that "private" settings +# are correct when an authenticated user creates things inside their "private" environment. +class RBACAgent: + """Class that handles galaxy security""" + permitted_actions = Bunch( + EDIT_METADATA = 'edit_metadata', + MANAGE_PERMISSIONS = 'manage_permissions', + ACCESS = 'access' + ) + def allow_action( self, user, action, **kwd ): + raise 'No valid method of checking action (%s) on %s for user %s.' % ( action, kwd, user ) + def guess_derived_groups_permitted_actions_for_datasets( self, datasets = [] ): + raise "Unimplemented Method" + def associate_components( self, **kwd ): + raise 'No valid method of associating provided components: %s' % kwd + def get_group( self, id ): + raise 'No valid method of retrieving group %s' % ( id ) + def create_group( self, **kwd ): + raise 'No valid method of creating group with %s' % ( kwd ) + def create_private_user_group( self, user ): + raise "Unimplemented Method" + def user_set_default_access( self, user, groups = None, history = False, dataset = False ): + raise "Unimplemented Method" + def setup_new_user( self, user ): + self.user_set_default_access( user, history = True, dataset = True ) + self.associate_components( user=user, group=self.get_public_group() ) + def history_set_default_access( self, history, groups=None, dataset=False ): + raise "Unimplemented Method" + def set_public_group( self, group ): + raise "Unimplemented Method" + def get_public_group( self ): + raise "Unimplemented Method" + def guess_public_group( self ): + raise "Unimplemented Method" + def set_dataset_groups( self, dataset, groups ): + raise "Unimplemented Method" + def set_dataset_permitted_actions( self, dataset ): + raise "Unimplemented Method" + def get_component_associations( self, **kwd ): + raise "Unimplemented Method" + def components_are_associated( self, **kwd ): + return bool( self.get_component_associations( **kwd ) ) + +class GalaxyRBACAgent( RBACAgent ): + def __init__( self, model, permitted_actions=None ): + self.model = model + if permitted_actions: + self.permitted_actions = permitted_actions + def allow_action( self, user, action, **kwd ): + if 'dataset' in kwd: + return self.allow_dataset_action( user, action, kwd['dataset'] ) + raise 'No valid method of checking action (%s) on %s for user %s.' % ( action, kwd, user ) + def allow_dataset_action( self, user, action, dataset ): + # TODO, Nate: Make sure this method is functionally correct. + """Returns true when user has permission to perform an action""" + while not isinstance( dataset, self.model.Dataset ): + dataset = dataset.dataset + # If dataset is in public group, we always return true for viewing and using + # This may need to change when the ability to alter groups and permitted_actions is allowed + if action in [ self.permitted_actions.dataset_actions.USE, self.permitted_actions.dataset_actions.VIEW ] and \ + self.components_are_associated( group = self.get_public_group(), dataset = dataset ): + return True + elif user is not None: + # Loop through permitted_actions and if allowed return true: + # Check permitted_actions associated with dataset through groups + for group_dataset_assoc in dataset.groups: + if self.components_are_associated( user = user, group = group_dataset_assoc.group ): + for pa in group_dataset_assoc.permitted_actions: + if action in pa.permitted_actions.actions: + return True + return False # No user and dataset not in public group, or user lacks permission + def guess_derived_groups_for_datasets( self, datasets=[] ): + # TODO, Nate: Make sure this method is functionally correct. + """Returns a list of groups for the output dataset based upon itself and provided datasets""" + access_groups = None + priority_access_group = None + for dataset in datasets: + # Determine access groups for output datasets - these groups are the + # intersection across all inputs. If we end up with no intersection + # between inputs, then we rely on priorities + if isinstance( dataset, self.model.HistoryDatasetAssociation ): + dataset = dataset.dataset + groups = [ data_group_assoc.group for data_group_assoc in dataset.groups ] + for group in groups: + if priority_access_group is None or priority_access_group.priority < group.priority: + priority_access_group = group + if access_groups is None: + access_groups = set( groups ) + else: + access_groups.intersection_update( set( groups ) ) + # Complete lists for output dataset access + if access_groups: + access_groups = list( access_groups) + else: + access_groups = [] + # If we have no groups left after intersection, take the highest priority group + if not access_groups: + if priority_access_group: + access_groups = [ priority_access_group ] + return access_groups + def get_group( self, id ): + return self.model.Group.get( id ) + raise 'No valid method of retrieving requested group %s' % ( id ) + def create_group( self, **kwd ): + rval = self.model.Group( **kwd ) + rval.flush() + return rval + raise 'No valid method of creating group with %s' % ( kwd ) + def associate_components( self, **kwd ): + # TODO, Nate: Make sure this method is functionally correct. + assert len( kwd ) == 2, 'You must specify exactly 2 Galaxy security components to associate.' + if 'dataset' in kwd: + if 'group' in kwd: + return self.associate_group_dataset( kwd['group'], kwd['dataset'] ) + elif 'user' in kwd: + if 'group' in kwd: + return self.associate_user_group( kwd['user'], kwd['group'] ) + raise 'No valid method of associating provided components: %s' % kwd + def associate_group_dataset( self, group, dataset, permitted_actions=[] ): + # TODO, Nate: Make sure this method is functionally correct. + # TODO: For now, just take the dataset's permitted_actions, but we need to make sure + # we can associate group permitted_actions if necessary - need to look into this... + if not permitted_actions: + if isinstance( dataset.permitted_actions, Bunch ): + permitted_actions = dataset.permitted_actions.__dict__.values() + else: + permitted_actions = dataset.permitted_actions + log.debug("In associate_group_dataset, permitted_actions: %s" %str(permitted_actions) ) + assoc = self.model.GroupDatasetAssociation( group, dataset, permitted_actions ) + assoc.flush() + return assoc + def associate_user_group( self, user, group ): + assoc = self.model.UserGroupAssociation( user, group ) + assoc.flush() + return assoc + def create_private_user_group( self, user ): + # TODO, Nate: Make sure this method is functionally correct. + # Create private group + group = self.model.Group( user.email, priority = 10 ) + group.flush() + # Add user to group + self.associate_components( group=group, user=user ) + group.flush() + return group + def user_set_default_access( self, user, groups = None, history = False, dataset = False ): + # TODO, Nate: Make sure this method is functionally correct with permitted actions set appropriately. + if groups is None: + groups = [ self.get_public_group(), self.create_private_user_group( user ) ] + if groups is not None: + for assoc in user.default_groups: #this is the association not the actual group + assoc.delete() + assoc.flush() + for group in groups: + log.debug("In user_set_default_access, group: %s" %str(group)) + if isinstance( group, self.model.Group ): + permitted_actions = group.permitted_actions.__dict__.values() + else: + permitted_actions = group.permitted_actions + log.debug("In user_set_default_access, permitted_actions: %s" % str( permitted_actions)) + assoc = self.model.DefaultUserGroupAssociation( user, group, permitted_actions ) + assoc.flush() + if history: + for history in user.histories: + self.history_set_default_access( history, groups=groups, dataset=dataset ) + def history_set_default_access( self, history, groups=None, dataset=False ): + # TODO, Nate: Make sure this method is functionally correct with permitted actions set appropriately. + if groups is None: + if history.user: + groups = history.user.default_groups + else: + groups = [ self.get_public_group() ] + if groups is not None: + for assoc in history.default_groups: #this is the association not the actual group + assoc.delete() + assoc.flush() + for group in groups: + log.debug("In history_set_default_access, group: %s" %str(group)) + if isinstance( group, self.model.Group ): + permitted_actions = group.permitted_actions.__dict__.values() + else: + permitted_actions = group.permitted_actions + log.debug("In history_set_default_access, permitted_actions: %s" % str( permitted_actions)) + assoc = self.model.DefaultHistoryGroupAssociation( history, group, permitted_actions ) + assoc.flush() + if dataset: + for data in history.datasets: + for hda in data.dataset.history_associations: + if history.user and hda.history not in history.user.histories: + self.set_dataset_groups( data.dataset, [ self.get_public_group() ] ) + break + else: + self.set_dataset_groups( data.dataset, groups ) + def get_public_group( self ): + return self.model.Group.get_public_group() + def set_public_group( self, group ): + return self.model.Group.set_public_group( group ) + def guess_public_group( self ): + return self.model.Group.guess_public_group() + def set_dataset_groups( self, dataset, groups ): + # TODO, Nate: Make sure this method is functionally correct. + if isinstance( dataset, self.model.HistoryDatasetAssociation ): + dataset = dataset.dataset + for group_dataset_assoc in dataset.groups: + group_dataset_assoc.delete() + group_dataset_assoc.flush() + for group in groups: + if not isinstance( group, self.model.Group ): + group = group.group + log.debug("In set_dataset_groups, before elf.associate_components, dataset: %s, group: %s" % ( str(dataset), str(group))) + self.associate_components( dataset=dataset, group=group ) + def get_component_associations( self, **kwd ): + # TODO, Nate: Make sure this method is functionally correct. + assert len( kwd ) == 2, 'You must specify exactly 2 Galaxy security components to check for associations.' + if 'dataset' in kwd: + if 'group' in kwd: + return self.model.GroupDatasetAssociation.get_by( group_id = kwd['group'].id, dataset_id = kwd['dataset'].id ) + elif 'user' in kwd: + if 'group' in kwd: + return self.model.UserGroupAssociation.get_by( group_id = kwd['group'].id, user_id = kwd['user'].id ) + raise 'No valid method of associating provided components: %s' % kwd diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index a56e6a829b4..7b5c4b0d305 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -1085,6 +1085,8 @@ class Tool: else: visible = False ext = fields.pop(0).lower() child_dataset = self.app.model.HistoryDatasetAssociation( extension=ext, parent_id=outdata.id, designation=designation, visible=visible, dbkey=outdata.dbkey, create_dataset=True ) + # TODO, Nate: Make sure the following is functionally correct. + self.app.security_agent.set_dataset_groups( child_dataset.dataset, outdata.dataset.groups ) # Move data from temp location to dataset location shutil.move( filename, child_dataset.file_name ) child_dataset.flush() @@ -1121,6 +1123,8 @@ class Tool: ext = fields.pop(0).lower() # Create new primary dataset primary_data = self.app.model.HistoryDatasetAssociation( extension=ext, designation=designation, visible=visible, dbkey=outdata.dbkey, create_dataset=True ) + # TODO, Nate: Make sure the following is functionally correct. + self.app.security_agent.set_dataset_groups( primary_data.dataset, outdata.dataset.groups ) primary_data.flush() # Move data from temp location to dataset location shutil.move( filename, primary_data.file_name ) diff --git a/lib/galaxy/tools/actions/__init__.py b/lib/galaxy/tools/actions/__init__.py index f2bb0cd8185..deb54fe6a0a 100644 --- a/lib/galaxy/tools/actions/__init__.py +++ b/lib/galaxy/tools/actions/__init__.py @@ -43,6 +43,9 @@ class DefaultToolAction( object ): assoc.flush() data = new_data break + # TODO, Nate: Make sure the permitted actions here are appropriate. + if data and not trans.app.security_agent.allow_action( trans.user, data.permitted_actions.USE, dataset=data ): + raise "User does not have permission to use a dataset (%s) provided for input." % data.id return data if isinstance( input, DataToolParameter ): if isinstance( value, list ): @@ -79,6 +82,15 @@ class DefaultToolAction( object ): data = NoneDataset( datatypes_registry = trans.app.datatypes_registry ) if data.dbkey not in [None, '?']: input_dbkey = data.dbkey + + # Determine output dataset permitted_actions list + existing_datasets = [ inp for inp in inp_data.values() if inp ] + if existing_datasets: + output_access_groups = trans.app.security_agent.guess_derived_groups_for_datasets( existing_datasets ) + else: + # No valid inputs, we will use history defaults + output_access_groups = [ group.group for group in trans.history.default_groups ] + # Build name for output datasets based on tool name and input names if len( input_names ) == 1: on_text = input_names[0] @@ -120,6 +132,7 @@ class DefaultToolAction( object ): data = trans.app.model.HistoryDatasetAssociation( extension=ext, create_dataset=True ) # Commit the dataset immediately so it gets database assigned unique id data.flush() + trans.app.security_agent.set_dataset_groups( data.dataset, output_access_groups ) # Create an empty file immediately open( data.file_name, "w" ).close() # This may not be neccesary with the new parent/child associations @@ -183,6 +196,9 @@ class DefaultToolAction( object ): job.add_parameter( name, value ) for name, dataset in inp_data.iteritems(): if dataset: + # TODO, Nate: Make sure the permitted actions here are appropriate. + if not trans.app.security_agent.allow_action( trans.user, dataset.permitted_actions.USE, dataset=dataset ): + raise "User does not have permission to use a dataset (%s) provided for input." % data.id job.add_input_dataset( name, dataset ) else: job.add_input_dataset( name, None ) diff --git a/lib/galaxy/tools/actions/upload.py b/lib/galaxy/tools/actions/upload.py index b3904436a63..a9623ae40e0 100644 --- a/lib/galaxy/tools/actions/upload.py +++ b/lib/galaxy/tools/actions/upload.py @@ -65,8 +65,10 @@ class UploadToolAction( object ): return dict( output=data_list[0] ) def upload_empty(self, trans, err_code, err_msg): - data = trans.app.model.HistoryDatasetAssociation( create_dataset = True ) - data.name = err_code + data = trans.app.model.HistoryDatasetAssociation( create_dataset=True ) + # TODO, Nate: Make sure the following is appropriate. + trans.app.security_agent.set_dataset_groups( data.dataset, trans.history.default_groups ) + data.name = err_code data.extension = "txt" data.dbkey = "?" data.info = err_msg @@ -85,12 +87,12 @@ class UploadToolAction( object ): if not os.path.getsize( temp_name ) > 0: raise BadFileException( "you attempted to upload an empty file." ) - # See if we have a gzipped file, which, if it passes our restrictions, we'll decompress on the fly. + # See if we have a gzipped file, which, if it passes our restrictions, we'll uncompress on the fly. is_gzipped, is_valid = self.check_gzip( temp_name ) if is_gzipped and not is_valid: raise BadFileException( "you attempted to upload an inappropriate file." ) elif is_gzipped and is_valid: - #We need to decompress the temp_name file + # We need to uncompress the temp_name file CHUNK_SIZE = 2**20 # 1Mb fd, uncompressed = tempfile.mkstemp() gzipped_file = gzip.GzipFile( temp_name ) @@ -159,6 +161,8 @@ class UploadToolAction( object ): info = 'uploaded %s file' %data_type data = trans.app.model.HistoryDatasetAssociation( history = trans.history, extension = ext, create_dataset = True ) + # TODO, Nate: Make sure the following is appropriate. + trans.app.security_agent.set_dataset_groups( data.dataset, trans.history.default_groups ) data.name = file_name data.dbkey = dbkey data.info = info diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py index b10b51bdb5e..d889d9dd79d 100644 --- a/lib/galaxy/tools/parameters/basic.py +++ b/lib/galaxy/tools/parameters/basic.py @@ -972,6 +972,8 @@ class DrillDownSelectToolParameter( ToolParameter ): class DataToolParameter( ToolParameter ): + # TODO, Nate: Make sure the following unit tests appropriately test the dataset security + # components. Add as many additional tests as necessary. """ Parameter that takes on one (or many) or a specific set of values. @@ -979,19 +981,35 @@ class DataToolParameter( ToolParameter ): displayed as radio buttons and multiple selects as a set of checkboxes >>> # Mock up a history (not connected to database) - >>> from galaxy.model import History, HistoryDatasetAssociation + >>> from galaxy.model import History, HistoryDatasetAssociation, User, Group >>> from galaxy.util.bunch import Bunch + >>> from galaxy.security import GalaxyRBACAgent + >>> import galaxy.model + >>> security_agent = GalaxyRBACAgent( galaxy.model ) >>> hist = History() >>> hist.flush() - >>> hist.add_dataset( HistoryDatasetAssociation( id=1, extension='txt', create_dataset=True ) ) - >>> hist.add_dataset( HistoryDatasetAssociation( id=2, extension='bed', create_dataset=True ) ) - >>> hist.add_dataset( HistoryDatasetAssociation( id=3, extension='fasta', create_dataset=True ) ) - >>> hist.add_dataset( HistoryDatasetAssociation( id=4, extension='png', create_dataset=True ) ) - >>> hist.add_dataset( HistoryDatasetAssociation( id=5, extension='interval', create_dataset=True ) ) + >>> group = Group( 'test' ) + >>> group.flush() + >>> Group.public_id = group.id + >>> dataset1 = HistoryDatasetAssociation( id=1, extension='txt', create_dataset=True ) + >>> security_agent.set_dataset_groups( dataset1, [ group ] ) + >>> dataset2 = HistoryDatasetAssociation( id=2, extension='bed', create_dataset=True ) + >>> security_agent.set_dataset_groups( dataset2, [ group ] ) + >>> dataset3 = HistoryDatasetAssociation( id=3, extension='fasta', create_dataset=True ) + >>> security_agent.set_dataset_groups( dataset3, [ group ] ) + >>> dataset4 = HistoryDatasetAssociation( id=4, extension='png', create_dataset=True ) + >>> security_agent.set_dataset_groups( dataset4, [ group ] ) + >>> dataset5 = HistoryDatasetAssociation( id=5, extension='interval', create_dataset=True ) + >>> security_agent.set_dataset_groups( dataset5, [ group ] ) + >>> hist.add_dataset( dataset1 ) + >>> hist.add_dataset( dataset2 ) + >>> hist.add_dataset( dataset3 ) + >>> hist.add_dataset( dataset4 ) + >>> hist.add_dataset( dataset5 ) >>> p = DataToolParameter( None, XML( '' ) ) >>> print p.name blah - >>> print p.get_html( trans=Bunch( history=hist ) ) + >>> print p.get_html( trans=Bunch( history=hist, user=None, app=Bunch( security_agent = security_agent ) ) ) +
+ + <% checked = "" %> + %if not data.dataset.has_group( trans.app.model.Group.get_public_group() ): + <% checked = " checked" %> + %endif +
+ +
+
+
+ This will prevent other users from viewing or utilizing this dataset, even if you share your history with them. +
+
+
+
+ +
+ + + +%endif diff --git a/templates/history/options.mako b/templates/history/options.mako index 4faa967742d..4bebc932dca 100644 --- a/templates/history/options.mako +++ b/templates/history/options.mako @@ -18,6 +18,7 @@ %endif %if app.config.enable_beta_features:
  • Construct workflow from the current history
  • +
  • Change default permitted actions for the current history
  • %endif
  • Share current history %endif diff --git a/templates/history/permissions.mako b/templates/history/permissions.mako new file mode 100644 index 00000000000..6607f002c41 --- /dev/null +++ b/templates/history/permissions.mako @@ -0,0 +1,42 @@ +<%inherit file="/base.mako"/> +<%def name="title()">Change Default History Permitted Actions + +%if trans.user: +
    +
    Change Default History Permitted Actions
    +
    +
    +
    + <% user_groups = [ assoc.group for assoc in trans.user.groups ] %> + <% cur_groups = [ assoc.group for assoc in trans.get_history().default_groups ] %> +
    + + + %for group in user_groups: + + %endfor +
    GroupInOut
    ${group.name}
    +
    + +
    + +
    + This will change the default permitted actions assigned to new datasets for your current history. +
    +
    +
    +
    + +
    +
    +
    +
    +%endif \ No newline at end of file diff --git a/templates/root/history_common.mako b/templates/root/history_common.mako index 877705614ed..607745ab20b 100644 --- a/templates/root/history_common.mako +++ b/templates/root/history_common.mako @@ -32,7 +32,9 @@ ## Body for history items, extra info and actions, data "peek"
    - %if data_state == "queued": + %if not trans.app.security_agent.allow_action( trans.user, data.permitted_actions.VIEW, dataset = data.dataset ): +
    You do not have permision to view this dataset.
    + %elif data_state == "queued":
    Job is waiting to run
    %elif data_state == "running":
    Job is currently running
    diff --git a/templates/user/index.mako b/templates/user/index.mako index 2b11262cece..1047466aad0 100644 --- a/templates/user/index.mako +++ b/templates/user/index.mako @@ -8,6 +8,9 @@ %else: diff --git a/templates/user/permissions.mako b/templates/user/permissions.mako new file mode 100644 index 00000000000..7b6b90f976d --- /dev/null +++ b/templates/user/permissions.mako @@ -0,0 +1,42 @@ +<%inherit file="/base.mako"/> +<%def name="title()">Change Default History Permitted Actions + +%if trans.user: +
    +
    Change Default Permitted Actions for new Histories
    +
    +
    +
    + <% user_groups = [ assoc.group for assoc in trans.user.groups ] %> + <% cur_groups = [ assoc.group for assoc in trans.user.default_groups ] %> +
    + + + %for group in user_groups: + + %endfor +
    GroupInOut
    ${group.name}
    +
    + +
    + +
    + This will change the default permitted actions assigned to new datasets for new histories. +
    +
    +
    +
    + +
    +
    +
    +
    +%endif \ No newline at end of file diff --git a/tools/data_source/encode_import_code.py b/tools/data_source/encode_import_code.py index 09a6e8a9823..ddfbb0241e0 100644 --- a/tools/data_source/encode_import_code.py +++ b/tools/data_source/encode_import_code.py @@ -5,7 +5,8 @@ from shutil import copyfile #post processing, set build for data and add additional data to history def exec_after_process(app, inp_data, out_data, param_dict, tool, stdout, stderr): - history = out_data.items()[0][1].history + base_dataset = out_data.items()[0][1] + history = base_dataset.history if history == None: print "unknown history!" return @@ -37,6 +38,8 @@ def exec_after_process(app, inp_data, out_data, param_dict, tool, stdout, stderr newdata.extension = file_type newdata.name = basic_name + " (" + description + ")" history.add_dataset( newdata ) + #TODO, Nate: Make sure the following is functionally correct + app.security_agent.set_dataset_groups( newdata.dataset, base_dataset.dataset.groups ) app.model.flush() try: copyfile(filepath,newdata.file_name) diff --git a/tools/data_source/microbial_import_code.py b/tools/data_source/microbial_import_code.py index b6bc2f6bd85..e8816f093ff 100644 --- a/tools/data_source/microbial_import_code.py +++ b/tools/data_source/microbial_import_code.py @@ -84,7 +84,8 @@ from galaxy import datatypes, config, jobs from shutil import copyfile def exec_after_process(app, inp_data, out_data, param_dict, tool, stdout, stderr): - history = out_data.items()[0][1].history + base_dataset = out_data.items()[0][1] + history = base_dataset.history if history == None: print "unknown history!" return @@ -128,6 +129,8 @@ def exec_after_process(app, inp_data, out_data, param_dict, tool, stdout, stderr newdata.extension = file_type newdata.name = basic_name + " (" + microbe_info[kingdom][org]['chrs'][chr]['data'][description]['feature'] +" for "+microbe_info[kingdom][org]['name']+":"+chr + ")" newdata.flush() + #TODO, Nate: Make sure the following is functionally correct + app.security_agent.set_dataset_groups( newdata.dataset, base_dataset.dataset.groups ) history.add_dataset( newdata ) app.model.flush() try: diff --git a/tools/maf/maf_to_bed_code.py b/tools/maf/maf_to_bed_code.py index c8f1e905e8e..428486b3e37 100644 --- a/tools/maf/maf_to_bed_code.py +++ b/tools/maf/maf_to_bed_code.py @@ -27,12 +27,13 @@ def exec_after_process(app, inp_data, out_data, param_dict, tool, stdout, stderr fields = line.split("\t") dbkey = fields[1] filepath = fields[2] - newdata = app.model.HistoryDatasetAssociation( create_dataset = True ) newdata.extension = "bed" newdata.name = basic_name + " (" + dbkey + ")" newdata.flush() history.add_dataset( newdata ) + #TODO, Nate: Make sure the following is functionally correct + app.security_agent.set_dataset_groups( newdata.dataset, output_data.dataset.groups ) newdata.flush() history.flush() app.model.flush()