jenkins-bot has submitted this change and it was merged. ( https://gerrit.wikimedia.org/r/364133 )
Change subject: Create new tools ...................................................................... Create new tools Allow Tool Labs members to create new tool accounts. Tool creation will also create a basic ToolInfo record for the new tool. Bug: T149458 Change-Id: I1b112b7d04dcf20771c938fd67ac302f6c0b4c3f --- M contrib/del-tool.sh M striker/settings.py M striker/templates/goals/TOOL_MAINTAINER.html A striker/templates/tools/create.html M striker/templates/tools/index.html M striker/tools/forms.py M striker/tools/models.py M striker/tools/urls.py A striker/tools/utils.py M striker/tools/views.py 10 files changed, 427 insertions(+), 19 deletions(-) Approvals: BryanDavis: Looks good to me, approved jenkins-bot: Verified diff --git a/contrib/del-tool.sh b/contrib/del-tool.sh index cc9aead..ac86603 100755 --- a/contrib/del-tool.sh +++ b/contrib/del-tool.sh @@ -2,7 +2,7 @@ # Hack a tool into the testing LDAP server setup with MediaWiki-Vagrant's # role::striker. # -# Usage: add-tool.sh NAME [DN_OF_MAINTAINER] +# Usage: del-tool.sh NAME [DN_OF_MAINTAINER] TOOL=${1:?TOOL required} BASE_DN="dc=wmftest,dc=net" @@ -13,4 +13,10 @@ /usr/bin/ldapadd -x -D "${ADMIN_DN}" -w "${ADMIN_PASS}" <<LDIF dn: cn=tools.${TOOL},${TOOL_BASE_DN} changetype: delete + +dn: uid=tools.${TOOL},ou=people,${TOOL_BASE_DN} +changetype: delete + +dn: cn=runas-tools.${TOOL},ou=sudoers,cn=tools,ou=projects,${BASE_DN} +changetype: delete LDIF diff --git a/striker/settings.py b/striker/settings.py index 1b5c312..0afdbad 100644 --- a/striker/settings.py +++ b/striker/settings.py @@ -384,6 +384,9 @@ TOOLS_TOOL_BASE_DN = ini.get('ldap', 'TOOLS_TOOL_BASE_DN') TOOLS_TOOL_LABS_GROUP_NAME = ini.get('ldap', 'TOOLS_TOOL_LABS_GROUP_NAME') +# == Project settings == +PROJECTS_BASE_DN = 'ou=projects,{}'.format(ini.get('ldap', 'BASE_DN')) + # == OATH settings == OATHMIDDLEWARE_REDIRECT = 'labsauth:oath' diff --git a/striker/templates/goals/TOOL_MAINTAINER.html b/striker/templates/goals/TOOL_MAINTAINER.html index d7f9f2d..d0bdd70 100644 --- a/striker/templates/goals/TOOL_MAINTAINER.html +++ b/striker/templates/goals/TOOL_MAINTAINER.html @@ -4,5 +4,6 @@ {% block title %}{% trans "Create or join a tool" %}{% endblock %} {% block body %} -<p>{% blocktrans with url='https://tools.wmflabs.org/' %}Your account is not associated with any tools. Visit <a href="{{ url }}">tools.wmflabs.org</a> to find a existing tool to join or create a new tool.{% endblocktrans %}</p> +{% url "tools:index" as tools_url %} +<p>{% blocktrans with url='' %}Your account is not associated with any tools. Visit <a href="{{ tools_url }}">the Tools page</a> to find a existing tool to join or create a new tool.{% endblocktrans %}</p> {% endblock %} diff --git a/striker/templates/tools/create.html b/striker/templates/tools/create.html new file mode 100644 index 0000000..f26cdc7 --- /dev/null +++ b/striker/templates/tools/create.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% load bootstrap3 %} +{% load i18n %} +{% load staticfiles %} + +{% block title %}{% trans "Create Tool account" %}{% endblock %} + +{% block pre_content %} +<ol class="breadcrumb"> + <li><a href="{% url 'tools:index' %}">{% trans "Tools" %}</a></li> + <li class="active">{% trans "Create Tool account" %}</li> +</ol> +{{ block.super }} +{% endblock %} + +{% block content %} +<div class="container-fluid"> + <div class="row"> + <div class="panel panel-default"> + <div class="panel-heading"> + <h3 class="panel-title">{% bootstrap_icon "wrench" %} {% trans "Create Tool account" %}</h3> + </div> + <div class="panel-body"> + <p>{% blocktrans with help="https://wikitech.wikimedia.org/wiki/Help:Tool_Labs#What_is_a_Tool_account.3F" %}A Tool account is the "user" associated with a Tool on Tool Labs. The account allows multiple Tool Labs users to collaborate on creating and maintaining a tool. Maintainers may have more than one tool account, and tool accounts may have more than one maintainer. See <a href="{{ help }}">help on wikitech</a> for more information.{% endblocktrans %}</p> + <form method="post" action="{% url 'tools:tool_create' %}" class="form parsley"> + {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + <input class="btn btn-success" type="submit" value="{% trans "Create" %}" /> + {% endbuttons %} + </form> + </div> + </div> + </div> +</div> +{% endblock %} + +{% block js %} +{{ block.super }} +<script lang="javascript" src="{% static 'js/parsley.min.js' %}"></script> +<script lang="javascript" src="{% static 'js/parsley-bootstrap.js' %}"></script> +{{ form.media }} +{% endblock %} diff --git a/striker/templates/tools/index.html b/striker/templates/tools/index.html index 10cfb5d..a44db7d 100644 --- a/striker/templates/tools/index.html +++ b/striker/templates/tools/index.html @@ -11,11 +11,14 @@ <div class="panel-heading">{% bootstrap_icon "star" %} {% trans "Your tools" %}</div> {% if not user.is_anonymous %} <div class="list-group"> + {% if member %} + <a class="list-group-item" href="{% url 'tools:tool_create' %}">{% bootstrap_icon "plus-sign" %} {% trans "Create Tool account" %}</a> + {% endif %} {% for tool in my_tools %} <a href="{% url 'tools:tool' tool=tool %}" class="list-group-item">{{ tool.name }}</a> {% empty %} {% if member %} - <div class="list-group-item">{% blocktrans with url='https://tools.wmflabs.org/' %}Your account is not associated with any tools. Visit <a href="{{ url }}">tools.wmflabs.org</a> to find a existing tool to join or create a new tool.{% endblocktrans %}</div> + <div class="list-group-item">{% blocktrans %}Your account is not associated with any tools.{% endblocktrans %}</div> {% else %} <div class="list-group-item"> <p>{% blocktrans %}Request membership in the Tool Labs project so you can create your own tool and help maintain existing tools.{% endblocktrans %}</p> diff --git a/striker/tools/forms.py b/striker/tools/forms.py index 47a821f..89bfe8b 100644 --- a/striker/tools/forms.py +++ b/striker/tools/forms.py @@ -21,15 +21,19 @@ import re from django import forms +from django.core import validators +from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from dal import autocomplete from parsley.decorators import parsleyfy from striker import phabricator +from striker.tools import utils from striker.tools.models import AccessRequest from striker.tools.models import SoftwareLicense from striker.tools.models import ToolInfo +from striker.tools.models import ToolInfoTag phab = phabricator.Client.default_client() @@ -194,3 +198,95 @@ class ToolInfoPublicForm(ToolInfoForm): class Meta(ToolInfoForm.Meta): exclude = ('name', 'tool', 'license', 'authors', 'is_webservice') + + +@parsleyfy +class ToolCreateForm(forms.Form): + # Unix username regex suggested by useradd(8). + # We don't allow a leading '_' or trailing '$' however. + RE_NAME = r'^[a-z][a-z0-9_-]{0,31}$' + NAME_ERR_MSG = _( + 'Must start with a-z, and can only contain ' + 'lowercase a-z, 0-9, _, and - characters.' + ) + + name = forms.CharField( + label=_('Unique tool name'), + help_text=_( + "The tool name is used as part of the URL for the tool's " + "webservice." + ), + widget=forms.TextInput( + attrs={ + 'autofocus': 'autofocus', + 'placeholder': _('A unique name for your tool'), + # Parsley gets confused if {value} is url encoded, so wrap in + # mark_safe(). + # FIXME: I tried everything I could think of to use + # urlresolvers.reverse_lazy and I just couldn't get it to work + # with mark_safe(). I would get either the URL encoded + # property value or the __str__ of a wrapper object. + 'data-parsley-remote': mark_safe( + '/tools/api/toolname/{value}'), + 'data-parsley-trigger': 'focusin focusout input', + 'data-parsley-remote-message': _( + 'Tool name is already in use or invalid.'), + 'data-parsley-pattern': RE_NAME, + 'data-parsley-pattern-message': NAME_ERR_MSG, + 'data-parsley-debounce': '500', + } + ), + max_length=32, + validators=[ + validators.RegexValidator(regex=RE_NAME, message=NAME_ERR_MSG), + ] + ) + title = forms.CharField( + label=_('Title'), + widget=forms.TextInput( + attrs={ + 'placeholder': _('A descriptive title for your tool'), + }, + ), + ) + description = forms.CharField( + label=_('Description of tool'), + widget=forms.Textarea( + attrs={ + 'placeholder': _( + 'A short summary of what your tool will do' + ), + 'rows': 5, + }, + ), + ) + license = forms.ModelChoiceField( + queryset=SoftwareLicense.objects.filter( + osi_approved=True).order_by('-recommended', 'slug'), + empty_label=_('-- Choose your software license --'), + label=_('Default software license'), + help_text=_( + 'Need help choosing a license? ' + 'Try <a href="{choose_a_license}">choosealicense.com</a>.' + ).format(choose_a_license='https://choosealicense.com/'), + ) + tags = forms.ModelMultipleChoiceField( + queryset=ToolInfoTag.objects.all().order_by('name'), + widget=autocomplete.ModelSelect2Multiple( + url='tools:tags_autocomplete', + ), + required=False, + ) + + def clean_name(self): + """Validate that name is available.""" + name = self.cleaned_data['name'] + if not utils.toolname_available(name): + raise forms.ValidationError(_('Tool name is already in use.')) + + # Check that it isn't banned by some abusefilter type rule + check = utils.check_toolname_create(name) + if check['ok'] is False: + raise forms.ValidationError(check['error']) + + return name diff --git a/striker/tools/models.py b/striker/tools/models.py index 59c1204..d656449 100644 --- a/striker/tools/models.py +++ b/striker/tools/models.py @@ -36,7 +36,7 @@ base_dn = settings.TOOLS_MAINTAINER_BASE_DN object_classes = ['posixAccount'] - username = fields.CharField(db_column='uid', unique=True) + username = fields.CharField(db_column='uid', primary_key=True) full_name = fields.CharField(db_column='cn') def __str__(self): @@ -56,7 +56,7 @@ objects = ToolManager() - cn = fields.CharField(db_column='cn', max_length=200, unique=True) + cn = fields.CharField(db_column='cn', max_length=200, primary_key=True) gid_number = fields.IntegerField(db_column='gidNumber', unique=True) members = fields.ListField(db_column='member') @@ -88,6 +88,44 @@ return self.name +class ToolUser(ldapdb.models.Model): + """Posix account that a tool runs as.""" + base_dn = 'ou=people,{}'.format(settings.TOOLS_TOOL_BASE_DN) + object_classes = [ + 'shadowAccount', + 'posixAccount', + 'person', + 'top' + ] + + uid = fields.CharField(db_column='uid', primary_key=True) + cn = fields.CharField(db_column='cn', unique=True) + sn = fields.CharField(db_column='sn', unique=True) + uid_number = fields.IntegerField(db_column='uidNumber', unique=True) + gid_number = fields.IntegerField(db_column='gidNumber') + home_directory = fields.CharField( + db_column='homeDirectory', max_length=200) + login_shell = fields.CharField(db_column='loginShell', max_length=64) + + def __str__(self): + return 'uid=%s,%s' % (self.uid, self.base_dn) + + +class SudoRole(ldapdb.models.Model): + base_dn = 'ou=sudoers,cn=tools,{}'.format(settings.PROJECTS_BASE_DN) + object_classes = ['sudoRole'] + + cn = fields.CharField(db_column='cn', primary_key=True) + users = fields.ListField(db_column='sudoUser') + hosts = fields.ListField(db_column='sudoHost') + commands = fields.ListField(db_column='sudoCommand') + options = fields.ListField(db_column='sudoOption') + runas_users = fields.ListField(db_column='sudoRunAsUser') + + def __str__(self): + return 'cn=%s,%s' % (self.cn, self.base_dn) + + class DiffusionRepo(models.Model): """Associate diffusion repos with Tools.""" tool = models.CharField(max_length=64) diff --git a/striker/tools/urls.py b/striker/tools/urls.py index ceadb76..f25b5e4 100644 --- a/striker/tools/urls.py +++ b/striker/tools/urls.py @@ -117,4 +117,18 @@ 'striker.tools.views.toolinfo', name='toolinfo' ), + urls.url( + r'create/$', + 'striker.tools.views.tool_create', + name='tool_create' + ), + urls.url(r'^api/', urls.include( + urls.patterns( + 'striker.tools.views', + urls.url( + r'^toolname/(?P<name>.+)$', + 'toolname_available', name='toolname'), + ), + namespace='api' + )), ] diff --git a/striker/tools/utils.py b/striker/tools/utils.py new file mode 100644 index 0000000..0e99450 --- /dev/null +++ b/striker/tools/utils.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017 Wikimedia Foundation and contributors. +# All Rights Reserved. +# +# This file is part of Striker. +# +# Striker is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Striker is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Striker. If not, see <http://www.gnu.org/licenses/>. + +import logging + +from django.contrib.auth.models import Group + +from striker.labsauth.utils import get_next_gid +from striker.register import utils as reg_utils +from striker.tools.models import SudoRole +from striker.tools.models import Tool +from striker.tools.models import ToolUser + + +logger = logging.getLogger(__name__) + + +def toolname_available(name): + toolname = 'tools.{0!s}'.format(name) + try: + Tool.objects.get(cn=toolname) + except Tool.DoesNotExist: + return True + else: + return False + + +def check_toolname_create(name): + """Check to see if a given name would be allowed as a tool name. + + Returns True if the username would be allowed. Returns either False or a + reason specifier if the username is not allowed. + Returns a dict with these keys: + - ok : Can a new user be created with this name (True/False) + - name : Canonicalized version of the given name + - error : Error message if ok is False; None otherwise + """ + return reg_utils.check_username_create(name) + + +def create_tool(name, user): + """Create a new tool account.""" + group_name = 'tools.{}'.format(name) + gid = get_next_gid() + + # Create group + tool = Tool(cn=group_name, gid_number=gid, members=[user.ldap_dn]) + tool.save() + + # Create user that tool runs as + service_user = ToolUser( + uid=group_name, + sn=group_name, + cn=group_name, + uid_number=gid, + gid_number=gid, + home_directory='/data/project/{}'.format(name), + ) + service_user.save() + + # Create sudoers rule to allow maintainers to act as tool user + sudoers = SudoRole( + cn='runas-{}'.format(group_name), + users=['%{}'.format(group_name)], + hosts=['ALL'], + commands=['ALL'], + options=['!authenticate'], + runas_users=[group_name], + ) + sudoers.save() + + # Mirror the tool as a Django Group so we can send notifications. + # This is normally done on user login by labsauth. + try: + maintainers, created = Group.objects.get_or_create(name=tool.cn) + user.groups.add(maintainers.id) + except Exception: + logger.exception( + 'Failed to add %s to django maintainers group', user) + + return tool diff --git a/striker/tools/views.py b/striker/tools/views.py index 89f2295..86dc7f9 100644 --- a/striker/tools/views.py +++ b/striker/tools/views.py @@ -29,6 +29,7 @@ from django.core import paginator from django.core import urlresolvers from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import PermissionDenied from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from django.db.utils import DatabaseError @@ -37,6 +38,7 @@ from django.utils import timezone from django.utils.text import slugify from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.cache import never_cache from dal import autocomplete from notifications.signals import notify @@ -47,9 +49,11 @@ from striker import mediawiki from striker import openstack from striker import phabricator +from striker.tools import utils from striker.tools.forms import AccessRequestAdminForm from striker.tools.forms import AccessRequestForm from striker.tools.forms import RepoCreateForm +from striker.tools.forms import ToolCreateForm from striker.tools.forms import ToolInfoForm from striker.tools.forms import ToolInfoPublicForm from striker.tools.models import AccessRequest @@ -110,6 +114,16 @@ def project_member(user): groups = user.groups.values_list('name', flat=True) return settings.TOOLS_TOOL_LABS_GROUP_NAME in groups + + +def require_tools_member(f): + """Ensure that the active user is a member of the tools project.""" + @functools.wraps(f) + def decorated(request, *args, **kwargs): + if project_member(request.user): + return f(request, *args, **kwargs) + raise PermissionDenied + return decorated def index(req): @@ -426,20 +440,26 @@ created_by=req.user) try: repo_model.save() - notify.send( - recipient=Group.objects.get(name=tool.cn), - sender=req.user, - verb=_('created'), - target=repo_model, - public=True, - level='info', - actions=[ - { - 'title': _('View repository'), - 'href': repo_model.get_absolute_url(), - }, - ], - ) + try: + maintainers = Group.objects.get(name=tool.cn) + except ObjectDoesNotExist: + # Can't find group for the tool + pass + else: + notify.send( + recipient=Group.objects.get(name=tool.cn), + sender=req.user, + verb=_('created new repo'), + target=repo_model, + public=True, + level='info', + actions=[ + { + 'title': _('View repository'), + 'href': repo_model.get_absolute_url(), + }, + ], + ) # Redirect to repo view return shortcuts.redirect( urlresolvers.reverse('tools:repo_view', kwargs={ @@ -706,3 +726,89 @@ encoder=PrettyPrintJSONEncoder, safe=False, ) + + +@require_tools_member +@login_required +def tool_create(req): + form = ToolCreateForm(req.POST or None, req.FILES or None) + if req.method == 'POST': + if form.is_valid(): + try: + tool = utils.create_tool(form.cleaned_data['name'], req.user) + except Exception: + logger.exception('utils.create_tool failed') + messages.error( + req, + _("Error creating tool. [req id: {id}]").format( + id=req.id)) + else: + messages.info( + req, _("Tool {} created".format(tool.name))) + try: + with reversion.create_revision(): + toolinfo = ToolInfo( + name='toolforge.{}'.format( + form.cleaned_data['name']), + tool=form.cleaned_data['name'], + title=form.cleaned_data['title'], + description=form.cleaned_data['description'], + license=form.cleaned_data['license'], + is_webservice=False, + ) + reversion.set_user(req.user) + reversion.set_comment('Tool created') + toolinfo.save() + toolinfo.authors.add(req.user) + if form.cleaned_data['tags']: + toolinfo.tags.add(*form.cleaned_data['tags']) + except Exception: + logger.exception('ToolInfo create failed') + messages.error( + req, + _("Error creating toolinfo. [req id: {id}]").format( + id=req.id)) + try: + maintainers = Group.objects.get(name=tool.cn) + except ObjectDoesNotExist: + # Can't find group for the tool + pass + else: + # Do not set tool as the notification target because the + # framework does not understand LDAP models. + notify.send( + recipient=maintainers, + sender=req.user, + verb=_('created tool {}').format(tool.name), + public=True, + level='info', + actions=[ + { + 'title': _('View tool'), + 'href': tool.get_absolute_url(), + }, + ], + ) + + return shortcuts.redirect(tool.get_absolute_url()) + ctx = { + 'form': form, + } + return shortcuts.render(req, 'tools/create.html', ctx) + + +@never_cache +def toolname_available(req, name): + """JSON callback for parsley validation of tool name. + + Kind of gross, but it returns a 406 status code when the name is not + available. This is to work with the limited choice of default response + validators in parsley. + """ + available = utils.toolname_available(name) + if available: + available = utils.check_toolname_create(name)['ok'] + status = 200 if available else 406 + return JsonResponse({ + 'available': available, + }, status=status) -- To view, visit https://gerrit.wikimedia.org/r/364133 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: I1b112b7d04dcf20771c938fd67ac302f6c0b4c3f Gerrit-PatchSet: 1 Gerrit-Project: labs/striker Gerrit-Branch: master Gerrit-Owner: BryanDavis <bda...@wikimedia.org> Gerrit-Reviewer: Andrew Bogott <abog...@wikimedia.org> Gerrit-Reviewer: BryanDavis <bda...@wikimedia.org> Gerrit-Reviewer: Madhuvishy <mviswanat...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits