diff --git a/pagure/forms.py b/pagure/forms.py index cdf9d3a..a9cc117 100644 --- a/pagure/forms.py +++ b/pagure/forms.py @@ -419,6 +419,20 @@ class UserSettingsForm(PagureForm): ) +class AddDeployKeyForm(PagureForm): + ''' Form to add a deploy key to a project. ''' + ssh_key = wtforms.TextField( + 'SSH Key *', + [wtforms.validators.Required()] + # TODO: Add an ssh key validator? + ) + pushaccess = wtforms.BooleanField( + 'Push access', + [wtforms.validators.optional()], + false_values=('false', '', False, 'False', 0, '0'), + ) + + class AddUserForm(PagureForm): ''' Form to add a user to a project. ''' user = wtforms.TextField( diff --git a/pagure/lib/__init__.py b/pagure/lib/__init__.py index de97d34..af9b15a 100644 --- a/pagure/lib/__init__.py +++ b/pagure/lib/__init__.py @@ -28,6 +28,7 @@ import tempfile import subprocess import urlparse import uuid +import werkzeug import bleach import redis @@ -213,6 +214,51 @@ def are_valid_ssh_keys(keys): for key in keys.split('\n')]) +def create_deploykeys_ssh_keys_on_disk(project, gitolite_keydir): + ''' Create the ssh keys for the projects' deploy keys on the key dir. + + This method does NOT support multiple ssh keys per deploy key. + ''' + if not gitolite_keydir: + # Nothing to do here, move right along + return + + #keyline_file = os.path.join(gitolite_keydir, + # 'keys_%i' % i, + # '%s.pub' % user.user) + # First remove deploykeys that no longer exist + keyfiles = ['deploykey_%s_%s.pub' % + (werkzeug.secure_filename(project.fullname), + key.id) + for key in project.deploykeys] + + project_key_dir = os.path.join(gitolite_keydir, 'deploykeys', + project.fullname) + if not os.path.exists(project_key_dir): + os.mkdir(project_key_dir) + + for keyfile in os.listdir(project_key_dir): + if keyfile not in keyfiles: + # This key is no longer in the project. Remove it. + os.remove(os.path.join(project_key_dir, keyfile)) + + for deploykey in project.deploykeys: + # See the comment in lib/git.py:write_gitolite_acls about why this + # name for a file is sane and does not inject a new security risk. + keyfile = 'deploykey_%s_%s' % ( + werkzeug.secure_filename(project.fullname), + deploykey.id) + if not os.path.exists(os.path.join(project_key_dir, keyfile)): + # We only take the very first key - deploykeys must be single keys + key = deploykey.public_ssh_key.split('\n')[0] + if not key: + continue + if not is_valid_ssh_key(key): + continue + with open(os.path.join(project_key_dir, keyfile), 'w') as f: + f.write(deploykey.public_ssh_key) + + def create_user_ssh_keys_on_disk(user, gitolite_keydir): ''' Create the ssh keys for the user on the specific folder. @@ -826,6 +872,52 @@ def edit_issue_tags( return msgs +def add_deploykey_to_project(session, project, ssh_key, pushaccess, user): + ''' Add a deploy key to a specified project. ''' + ssh_key = ssh_key.strip() + + if '\n' in ssh_key: + raise pagure.exceptions.PagureException( + 'Deploy key can only be single keys.' + ) + + ssh_short_key = is_valid_ssh_key(ssh_key) + if ssh_short_key in [None, False]: + raise pagure.exceptions.PagureException( + 'Deploy key invalid.' + ) + + # We are sure that this only contains a single key, but ssh-keygen still + # return a \n at the end + ssh_short_key = ssh_short_key.split('\n')[0] + + # Make sure that this key is not a deploy key in this or another project. + # If we dupe keys, gitolite might choke. + ssh_search_key = ssh_short_key.split(' ')[1] + if session.query(model.DeployKey).filter( + model.DeployKey.ssh_search_key==ssh_search_key).count() != 0: + raise pagure.exceptions.PagureException( + 'Deploy key already exists.' + ) + + user_obj = get_user(session, user) + new_key_obj = model.DeployKey( + project_id=project.id, + pushaccess=pushaccess, + public_ssh_key=ssh_key, + ssh_short_key=ssh_short_key, + ssh_search_key=ssh_search_key, + creator_user_id=user_obj.id) + + session.add(new_key_obj) + # Make sure we won't have SQLAlchemy error before we continue + session.flush() + + # We do not send any notifications on purpose + + return 'Deploy key added' + + def add_user_to_project(session, project, new_user, user): ''' Add a specified user to a specified project. ''' new_user_obj = get_user(session, new_user) diff --git a/pagure/lib/git.py b/pagure/lib/git.py index 46221e1..b722988 100644 --- a/pagure/lib/git.py +++ b/pagure/lib/git.py @@ -110,6 +110,22 @@ def write_gitolite_acls(session, configfile): for user in project.users: if user != project.user: config.append(' RW+ = %s' % user.user) + for deploykey in project.deploykeys: + access = 'R' + if deploykey.pushaccess: + access = 'RW+' + # Note: the replace of / with _ is because gitolite users can't + # contain a /. At first, this might look like deploy keys in a + # project called $namespace_$project would give access to the + # repos of a project $namespace/$project or vica versa, however + # this is NOT the case because we add the deploykey.id to the + # end of the deploykey name, which means it is unique. The + # project name is solely there to make it easier to determine + # what project created the deploykey for admins. + config.append(' %s = deploykey_%s_%s' % + (access, + werkzeug.secure_filename(project.fullname), + deploykey.id)) config.append('') with open(configfile, 'w') as stream: diff --git a/pagure/lib/model.py b/pagure/lib/model.py index d0bb402..51d9b5e 100644 --- a/pagure/lib/model.py +++ b/pagure/lib/model.py @@ -635,6 +635,44 @@ class ProjectUser(BASE): index=True) +class DeployKey(BASE): + """ Stores information about deployment keys. + + Table -- deploykeys + """ + + __tablename__ = 'deploykeys' + id = sa.Column(sa.Integer, primary_key=True) + project_id = sa.Column( + sa.Integer, + sa.ForeignKey( + 'projects.id', onupdate='CASCADE', ondelete='CASCADE', + )) + pushaccess = sa.Column(sa.Boolean, nullable=False, default=False) + public_ssh_key = sa.Column(sa.Text, nullable=False) + ssh_short_key = sa.Column(sa.Text, nullable=False) + ssh_search_key = sa.Column(sa.Text, nullable=False) + creator_user_id = sa.Column( + sa.Integer, + sa.ForeignKey( + 'users.id', onupdate='CASCADE', + ), + nullable=False, + index=True) + date_created = sa.Column(sa.DateTime, nullable=False, + default=datetime.datetime.utcnow) + + # Relations + project = relation( + 'Project', foreign_keys=[project_id], remote_side=[Project.id], + backref=backref( + 'deploykeys', cascade="delete, delete-orphan", single_parent=True) + ) + + creator_user = relation('User', foreign_keys=[creator_user_id], + remote_side=[User.id]) + + class Issue(BASE): """ Stores the issues reported on a project. diff --git a/pagure/templates/add_deploykey.html b/pagure/templates/add_deploykey.html new file mode 100644 index 0000000..6ff6dde --- /dev/null +++ b/pagure/templates/add_deploykey.html @@ -0,0 +1,42 @@ +{% extends "repo_master.html" %} +{% from "_formhelper.html" import render_field_in_row %} +{% from "_formhelper.html" import render_bootstrap_field %} + +{% set tag = "home" %} + +{% block header %} + +{% endblock %} + +{% block title %}Add deploy key - {{ + repo.namespace + '/' if repo.namespace }}{{ repo.name }}{% endblock %} + +{% block repo %} +
+
+
+ Add deploy key to the {{repo.name}} project +
+
+
+ +
+ + +
+ {{ render_bootstrap_field(form.pushaccess, field_description="Do you want to give this key push access?") }} + +

+ + + {{ form.csrf_token }} +

+
+
+
+
+ +{% endblock %} diff --git a/pagure/templates/settings.html b/pagure/templates/settings.html index c7dc777..1836852 100644 --- a/pagure/templates/settings.html +++ b/pagure/templates/settings.html @@ -462,6 +462,54 @@ {% endif %} +
+
+
+ Deploy Keys +
+
+ +

Below are this projects' deploy keys.

+ +

+ + add deploy key + +

+
+ +
+
+ + {% if plugins %}
diff --git a/pagure/ui/repo.py b/pagure/ui/repo.py index 7d7ef0f..6713176 100644 --- a/pagure/ui/repo.py +++ b/pagure/ui/repo.py @@ -1518,6 +1518,66 @@ def new_repo_hook_token(repo, username=None, namespace=None): namespace=namespace)) +@APP.route('//dropdeploykey/', methods=['POST']) +@APP.route('///dropdeploykey/', methods=['POST']) +@APP.route('/fork///dropdeploykey/', + methods=['POST']) +@APP.route('/fork////dropdeploykey/', + methods=['POST']) +@login_required +def remove_deploykey(repo, keyid, username=None, namespace=None): + """ Remove the specified deploy key from the project. + """ + + if admin_session_timedout(): + flask.flash('Action canceled, try it again', 'error') + url = flask.url_for( + 'view_settings', username=username, repo=repo, + namespace=namespace) + return flask.redirect( + flask.url_for('auth_login', next=url)) + + repo = flask.g.repo + + if not flask.g.repo_admin: + flask.abort( + 403, + 'You are not allowed to change the deploy keys for this project') + + form = pagure.forms.ConfirmationForm() + if form.validate_on_submit(): + keyids = [str(key.id) for key in repo.deploykeys] + + if str(keyid) not in keyids: + flask.flash( + 'Deploy key does not exist in project.', 'error') + return flask.redirect(flask.url_for( + '.view_settings', repo=repo.name, username=username, + namespace=repo.namespace,) + ) + + for key in repo.deploykeys: + if str(key.id) == str(keyid): + SESSION.delete(key) + break + try: + SESSION.commit() + pagure.lib.git.generate_gitolite_acls() + pagure.lib.create_deploykeys_ssh_keys_on_disk( + repo, + APP.config.get('GITOLITE_KEYDIR', None) + ) + flask.flash('Deploy key removed') + except SQLAlchemyError as err: # pragma: no cover + SESSION.rollback() + APP.logger.exception(err) + flask.flash('Deploy key could not be removed', 'error') + + return flask.redirect(flask.url_for( + '.view_settings', repo=repo.name, username=username, + namespace=namespace)) + + @APP.route('//dropuser/', methods=['POST']) @APP.route('///dropuser/', methods=['POST']) @APP.route('/fork///dropuser/', @@ -1577,6 +1637,72 @@ def remove_user(repo, userid, username=None, namespace=None): namespace=namespace)) +@APP.route('//adddeploykey/', methods=('GET', 'POST')) +@APP.route('//adddeploykey', methods=('GET', 'POST')) +@APP.route('///adddeploykey/', methods=('GET', 'POST')) +@APP.route('///adddeploykey', methods=('GET', 'POST')) +@APP.route('/fork///adddeploykey/', methods=('GET', 'POST')) +@APP.route('/fork///adddeploykey', methods=('GET', 'POST')) +@APP.route( + '/fork////adddeploykey/', + methods=('GET', 'POST')) +@APP.route( + '/fork////adddeploykey', + methods=('GET', 'POST')) +@login_required +def add_deploykey(repo, username=None, namespace=None): + """ Add the specified deploy key to the project. + """ + + if admin_session_timedout(): + if flask.request.method == 'POST': + flask.flash('Action canceled, try it again', 'error') + return flask.redirect( + flask.url_for('auth_login', next=flask.request.url)) + + repo = flask.g.repo + + if not flask.g.repo_admin: + flask.abort( + 403, + 'You are not allowed to add deploy keys to this project') + + form = pagure.forms.AddDeployKeyForm() + + if form.validate_on_submit(): + try: + msg = pagure.lib.add_deploykey_to_project( + SESSION, repo, + ssh_key=form.ssh_key.data, + pushaccess=form.pushaccess.data, + user=flask.g.fas_user.username, + ) + SESSION.commit() + pagure.lib.git.generate_gitolite_acls() + pagure.lib.create_deploykeys_ssh_keys_on_disk( + repo, + APP.config.get('GITOLITE_KEYDIR', None) + ) + flask.flash(msg) + return flask.redirect(flask.url_for( + '.view_settings', repo=repo.name, username=username, + namespace=namespace)) + except pagure.exceptions.PagureException as msg: + SESSION.rollback() + flask.flash(msg, 'error') + except SQLAlchemyError as err: # pragma: no cover + SESSION.rollback() + APP.logger.exception(err) + flask.flash('Deploy key could not be added', 'error') + + return flask.render_template( + 'add_deploykey.html', + form=form, + username=username, + repo=repo, + ) + + @APP.route('//adduser/', methods=('GET', 'POST')) @APP.route('//adduser', methods=('GET', 'POST')) @APP.route('///adduser/', methods=('GET', 'POST'))