#4320 add service accounts
Opened 5 years ago by karsten. Modified 4 years ago
karsten/pagure add_service_accounts  into  master

@@ -166,3 +166,9 @@ 

  # was running since before version 1.3 and if you care about backward

  # compatibility in your URLs.

  OLD_VIEW_COMMIT_ENABLED = False

+ 

+ # Service accounts

+ # Service accounts are special user accounts whose tokens take longer to expire

+ # Service account names need to stert with 'service:' so they can be easily identified.

+ #ENABLE_SERVICE_ACCOUNTS = False

+ 

file modified
+10
@@ -1594,6 +1594,16 @@ 

  Defaults to: ``True``

  

  

+ ENABLE_SERVICE_ACCOUNTS

+ ~~~~~~~~~~~~~~~~~~~~~~~

+ 

+ This configuration key allows to turn on or off service accounts.

+ Service accounts are special user accounts whose tokens take longer to

+ expire and who can have more than one admin to manage them

+ 

+ Defaults to: ``False``

+ 

+ 

  SESSION_COOKIE_NAME

  ~~~~~~~~~~~~~~~~~~~

  

file modified
+6
@@ -241,3 +241,9 @@ 

  #             'push_cert': {'cert': '',

  #                           'key': ''}}

  REPOSPANNER_REGIONS = {}

+ 

+ # Service accounts are special user accounts whose tokens take longer to

+ # expire and who can have more than one admin to manage them

+ # This configuration key allows to turn on or off service accounts.

+ # Service account names need to stert with 'service:' so they can be easily identified.

+ #ENABLE_SERVICE_ACCOUNTS = False

file modified
+31 -1
@@ -509,7 +509,37 @@ 

  def _get_user(username):

      """ Check if user exists or not

      """

+     temp = None

      try:

-         return pagure.lib.query.get_user(flask.g.session, username)

+         temp = pagure.lib.query.get_user(flask.g.session, username)

+         if isinstance(temp, list):

+             return temp[0]

+         else:

+             return temp

      except pagure.exceptions.PagureException as e:

          flask.abort(404, description="%s" % e)

+ 

+ 

+ def get_other_user(username=None):

+     """ returns obj of an user other than the one that is currently logged in,

+         if the current user is admin of this pagure instance

+         or the other user is a service account and the current user is an

+         admin of that service account.

+         returns obj of the current user is above conditions are not met

+     """

+     name = None

+     if username:

+         if pagure.utils.is_admin():

+             name = username

+         else:

+             # not pagure admin but might be a service account admin

+             this_user_name = flask.g.fas_user.username

+             the_other_user = pagure.lib.query.search_user(

+                 flask.g.session, username=username

+             )

+             if the_other_user.is_service:

+                 if this_user_name in the_other_user.servicemaintainers.split(

+                     ","

+                 ):

+                     name = the_other_user

+     return _get_user(name)

file modified
+16
@@ -690,6 +690,22 @@ 

              self.email.validators = [wtforms.validators.DataRequired()]

  

  

+ class ServiceAdminForm(PagureForm):

+     """ Form to add new service admins to a service acount """

+ 

+     admin = wtforms.StringField("admin", [wtforms.validators.DataRequired()])

+ 

+     def __init__(self, *args, **kwargs):

+         super(ServiceAdminForm, self).__init__(*args, **kwargs)

+         if "admins" in kwargs:

+             if kwargs["admins"]:

+                 self.admin.validators.append(

+                     wtforms.validators.NoneOf(kwargs["admins"])

+                 )

+         else:

+             self.admin.validators = [wtforms.validators.DataRequired()]

+ 

+ 

  class ProjectCommentForm(PagureForm):

      """ Form to represent project. """

  

file modified
+2
@@ -197,6 +197,8 @@ 

      id = sa.Column(sa.Integer, primary_key=True)

      user = sa.Column(sa.String(255), nullable=False, unique=True, index=True)

      fullname = sa.Column(sa.String(255), nullable=False, index=True)

+     is_service = sa.Column(sa.Boolean, nullable=True)

+     servicemaintainers = sa.Column(sa.Text, nullable=True)

      default_email = sa.Column(sa.Text, nullable=False)

      _settings = sa.Column(sa.Text, nullable=True)

  

file modified
+19 -2
@@ -179,7 +179,14 @@ 

      return nid + 1

  

  

- def search_user(session, username=None, email=None, token=None, pattern=None):

+ def search_user(

+     session,

+     username=None,

+     email=None,

+     token=None,

+     pattern=None,

+     is_service=None,

+ ):

      """ Searches the database for the user or users matching the given

      criterias.

  
@@ -194,6 +201,8 @@ 

      :type pattern: string or None

      :return: A single User object if any of username, email or token is

          specified, a list of User objects otherwise.

+     :type is_service: string or None

+     :kwarg is_service: A list of User objects that are service accounts

      :rtype: User or [User]

  

      """
@@ -214,6 +223,9 @@ 

          pattern = pattern.replace("*", "%")

          query = query.filter(model.User.user.like(pattern))

  

+     if is_service is not None:

+         query = query.filter(model.User.is_service == is_service)

+ 

      if any([username, email, token]):

          output = query.first()

      else:
@@ -4253,12 +4265,17 @@ 

  

      user = search_user(session, username=username)

  

+     if user.is_service:

+         expiredays = 180

+     else:

+         expiredays = 60

      token = pagure.lib.model.Token(

          id=pagure.lib.login.id_generator(64),

          user_id=user.id,

          project_id=project.id if project else None,

          description=description,

-         expiration=datetime.datetime.utcnow() + datetime.timedelta(days=60),

+         expiration=datetime.datetime.utcnow()

+         + datetime.timedelta(days=expiredays),

      )

      session.add(token)

      session.flush()

file modified
+16
@@ -98,6 +98,22 @@ 

      )

  

  

+ class NewServiceForm(FlaskForm):

+     """ Form to add a new service account to the local database. """

+ 

+     user = wtforms.StringField(

+         'Service Name  <span class="error">*</span>',

+         [wtforms.validators.DataRequired()],

+     )

+     fullname = wtforms.StringField(

+         "Description", [wtforms.validators.Optional()]

+     )

+     service_admins = wtforms.StringField(

+         'Comma separated list of service admins  <span class="error">*</span>',

+         [wtforms.validators.DataRequired()],

+     )

+ 

+ 

  class ChangePasswordForm(FlaskForm):

      """ Form to reset one's password in the local database. """

  

@@ -31,7 +31,7 @@ 

                        repo=repo.name, namespace=repo.namespace)

            }}" method="post">

            {% else %}

-           <form action="{{ url_for('ui_ns.add_api_user_token') }}" method="post">

+           <form action="{{ url_for('ui_ns.add_api_user_token', username=username) }}" method="post">

            {% endif %}

            {{ render_bootstrap_field(

                form.description, field_description="Small description of this API token") }}

@@ -0,0 +1,29 @@ 

+ {% extends "master.html" %}

+ {% from "_formhelper.html" import render_bootstrap_field %}

+ 

+ {% block title %}New Service Account{% endblock %}

+ {% set tag = "home" %}

+ 

+ {% block content %}

+ <div class="container">

+   <div class="row">

+     <div class="col-md-6 mx-auto pt-5">

+       {% if config.get('ENABLE_SERVICE_ACCOUNTS', False)  %}

+         <h4 class="text-center font-weight-bold mb-4">Create new service account</h4>

+         <form action="{{ url_for('ui_ns.new_service_account') }}" method="post">

+           {{ render_bootstrap_field(

+               form.user) }}

+           {{ render_bootstrap_field(

+               form.fullname) }}

+           {{ render_bootstrap_field(

+               form.service_admins) }}

+           <input class="btn btn-primary btn-block mt-4" type="submit" value="Create" title="Create service account">

+           {{ form.csrf_token }}

+         </form>

+       {% else %}

+         <h4 class="text-center font-weight-bold mb-4">You need to set ENABLE_SERVICE_ACCOUNTS to True in the configuration before you'll be able to do anything here</h4>

+       {% endif %}

+     </div>

+   </div>

+ </div>

+ {% endblock %}

@@ -74,6 +74,12 @@ 

                <a class="dropdown-item" href="{{

                  url_for('ui_ns.userprofile_starred', username=g.fas_user.username)

                  }}">My Starred Projects</a>

+               {% if config.get('ENABLE_SERVICE_ACCOUNTS', False)  %}

+                 <a class="dropdown-item" href="{{

+                   url_for('ui_ns.manage_services', username=g.fas_user.username)

+                   }}">Manage Services</a>

+               {% endif %}

+               <div class="dropdown-divider"></div>

                {% if config.get('ENABLE_TICKETS', True) %}

                <a class="dropdown-item" href="{{

                  url_for('ui_ns.view_user_issues', username=g.fas_user.username)

@@ -0,0 +1,40 @@ 

+ {% extends "master.html" %}

+ {% from "_formhelper.html" import render_bootstrap_field %}

+ {% from "_projectstring.html" import projectstring, projecticon %}

+ 

+ 

+ {% block title %}List of Service Accounts{% endblock %}

+ {% set tag = "home" %}

+ 

+ {% block content %}

+ <div class="container">

+   <div class="row">

+     {% if config.get('ENABLE_SERVICE_ACCOUNTS', False)  %}

+       <div class="col-md-6 mx-auto pt-5">

+         <h4 class="text-center font-weight-bold mb-4">List of service accounts</h4>

+         {% if service_accounts %}

+           <div class="media-body align-self-center">

+           {% for account in service_accounts %}

+             <h4 class="my-0 font-weight-bold">

+             <a href="{{ url_for('ui_ns.user_settings', username=account.user)}}">{{ account.user }}</a>

+             </h4>

+             {{ account.fullname }}

+           {% endfor %}

+           </div>

+         {% else %}

+           <h4 class="my-0 font-weight-bold">

+             None available yet

+           </h4>

+         {% endif %}

+         {% if g.admin %}

+           <a href="{{ url_for('ui_ns.new_service_account') }}">New Service Account </a>

+         {% endif %}

+       </div>

+     {% else %}

+       <div class="col-md-6 mx-auto pt-5">

+         <h4 class="text-center font-weight-bold mb-4">You need to set ENABLE_SERVICE_ACCOUNTS to True in the configuration before you'll be able to do anything here</h4>

+       </div>

+     {% endif %}

+   </div>

+ </div>

+ {% endblock %}

@@ -0,0 +1,37 @@ 

+ {% extends "master.html" %}

+ {% from "_formhelper.html" import render_bootstrap_field %}

+ 

+ {% block title %}Add Service Admin{% endblock %}

+ {% set tag = "home" %}

+ 

+ {% block content %}

+ <div class="container p-t-3">

+   <div class="col-md-8 col-md-offset-2">

+     <div class="card">

+       <div class="card-header">

+         <strong>Add new Service Admin</strong>

+       </div>

+       <div class="card-block">

+         {% if config.get('ENABLE_SERVICE_ACCOUNTS', False)  %}

+           {% if username %}

+             <form action="{{ url_for('ui_ns.add_service_admin', username=username) }}" method="post">

+               {{ render_bootstrap_field(form.admin) }}

+               <input type="submit" class="btn btn-primary" value="Add">

+               <input type="button" value="Cancel" class="btn btn-secondary" onclick="history.back();">

+               {{ form.csrf_token }}

+             </form>

+           {% else %}

+             <div class="col-md-6 mx-auto pt-5">

+               <h4 class="text-center font-weight-bold mb-4">Nothing for you to see here</h4>

+             </div>

+           {% endif %}

+         {% else %}

+           <div class="col-md-6 mx-auto pt-5">

+             <h4 class="text-center font-weight-bold mb-4">You need to set ENABLE_SERVICE_ACCOUNTS to True in the configuration before you'll be able to do anything here</h4>

I'm confused, does this means these templates can be accessed even if the service accounts are disabled?

+           </div>

+         {% endif %}

+       </div>

+     </div>

+   </div>

+ </div>

+ {% endblock %}

@@ -85,7 +85,7 @@ 

            {% endif %}

  

            {% if config.get('ENABLE_GIVE_PROJECTS', True)

-             and (repo.user.user == g.fas_user.username or pagure_admin)

+             and (repo.user.user == g.fas_user.username or g.admin)

              and not repo.is_fork %}

            <a class="nav-item nav-link" id="giveproject" data-toggle="tab"

              href="#giveproject-tab" role="tab" aria-controls="giveproject">Give Project</a>
@@ -1035,7 +1035,7 @@ 

            </div>

  

            {% if config.get('ENABLE_GIVE_PROJECTS', True)

-           and (repo.user.user == g.fas_user.username or pagure_admin)

+           and (repo.user.user == g.fas_user.username or g.admin)

            and not repo.is_fork %}

            <div class="tab-pane fade" id="giveproject-tab" role="tabpanel" aria-labelledby="giveproject-tab">

                <h3 class="font-weight-bold mb-3">

@@ -5,12 +5,28 @@ 

  {% block title %}{{ user.user }}'s settings{% endblock %}

  {% set tag = "users"%}

  

+ {% macro render_admin(admin, form) %}

+ <div class="list-group-item">

+   <span class="fa fa-envelope text-muted"></span> &nbsp;{{ admin }}

+     <form class="float-right" method="POST"

+         action="{{ url_for('ui_ns.remove_service_admin', username=user.user, admin=admin) }}">

+       <input type="hidden" value="{{ admin }}" name="admin" />

+       {{ form.csrf_token }}

+       <button

+         onclick="return confirm('Do you really want to remove the admin: {{ admin }}?');"

+         title="Remove admin" class="btn btn btn-outline-danger">

+         <i class="fa fa-trash fa-fw"></i>

+       </button>

+     </form>

+ </div>

+ {% endmacro %}

+ 

  {% macro render_email(email, form, validated=True) %}

  <div class="list-group-item {% if not validated %}disabled{% endif %}">

    <span class="fa fa-envelope text-muted"></span> &nbsp;{{ email.email }}

    {% if validated %}

      <form class="float-right" method="POST"

-         action="{{ url_for('ui_ns.remove_user_email') }}">

+         action="{{ url_for('ui_ns.remove_user_email', username=user.user) }}">

        <input type="hidden" value="{{ email.email }}" name="email" />

        {{ form.csrf_token }}

        <button title="Remove email" data-email="{{ email.email }}"
@@ -24,7 +40,7 @@ 

      </div>

      {% else %}

      <form class="inline" method="POST"

-       action="{{ url_for('ui_ns.set_default_email') }}" id="default_mail">

+       action="{{ url_for('ui_ns.set_default_email', username=user.user) }}" id="default_mail">

        <input type="hidden" value="{{ email.email }}" name="email" />

        {{ form.csrf_token }}

        <a class="float-right p-r-1 btn btn-outline-warning border-0 text-secondary mr-1 pointer submit-btn"
@@ -37,7 +53,7 @@ 

      <div class="float-right">

        <small>pending verification via email </small>

        <form class="inline" method="POST"

-         action="{{ url_for('ui_ns.reconfirm_email') }}" id="reconfirm_mail">

+         action="{{ url_for('ui_ns.reconfirm_email', username=user.user) }}" id="reconfirm_mail">

          <input type="hidden" value="{{ email.email }}" name="email" />

          {{ form.csrf_token }}

          <button data-form-id="reconfirm_mail"
@@ -59,7 +75,12 @@ 

          <div class="nav nav-tabs nav-sidetabs flex-column" id="nav-tab" role="tablist">

            <h5 class="pl-2 font-weight-bold text-muted">User Settings</h5>

            <a class="nav-item nav-link active" id="nav-basic-tab" data-toggle="tab" href="#nav-basic" role="tab" aria-controls="nav-basic" aria-selected="true">Profile</a>

-           <a class="nav-item nav-link" id="nav-email-tab" data-toggle="tab" href="#nav-email" role="tab" aria-controls="nav-email" aria-selected="true">Email Addresses</a>

+           {% if config.get('ENABLE_SERVICE_ACCOUNTS', False) and user.is_service %}

+               <a class="nav-item nav-link" id="nav-serviceaccountadmins-tab" data-toggle="tab"

+                   href="#nav-serviceaccountadmins" role="tab" aria-controls="nav-serviceaccountadmins" aria-selected="true">Admins for this Service Account</a>

+           {% else %}

+             <a class="nav-item nav-link" id="nav-email-tab" data-toggle="tab" href="#nav-email" role="tab" aria-controls="nav-email" aria-selected="true">Email Addresses</a>

+           {% endif %}

            <a class="nav-item nav-link" id="nav-api-tab" data-toggle="tab" href="#nav-api" role="tab" aria-controls="nav-api" aria-selected="true">API Keys</a>

            {% if config.get('LOCAL_SSH_KEY', True) %}

              <a class="nav-item nav-link" id="nav-ssh-tab" data-toggle="tab" href="#nav-ssh" role="tab" aria-controls="nav-ssh" aria-selected="true">SSH Keys</a>
@@ -75,7 +96,9 @@ 

            <h3 class="font-weight-bold mb-3">

              Basic Information

              {% if config.get('PAGURE_AUTH')=='local' %}

-                 <a class="btn btn-sm btn-outline-primary float-right" href="{{ url_for('ui_ns.change_password', username=g.fas_user.username) }}">Change password</a>

+                 {% if config.get('ENABLE_SERVICE_ACCOUNTS', False) and not user.is_service %}

+                     <a class="btn btn-sm btn-outline-primary float-right" href="{{ url_for('ui_ns.change_password', username=user.username) }}">Change password</a>

+                 {% endif %}

              {% endif %}

            </h3>

            <div class="row">
@@ -100,11 +123,31 @@ 

              </div>

            </div>

          </div>

+         {% if user.is_service %}

+           <div class="tab-pane fade" id="nav-serviceaccountadmins" role="tabpanel" aria-labelledby="nav-serviceaccountadmins-tab">

+             <h3 class="font-weight-bold mb-3">

+               Admins for this Service Account

+               <a class="btn btn-outline-primary btn-sm float-right" href="{{

+                 url_for('ui_ns.add_service_admin', username=user.user) }}">

+                   Add admin to Service Account

+               </a>

+             </h3>

+             <div class="row">

+               <div class="col">

+                 <div class="list-group">

+                     {% for admin in user.servicemaintainers.split(',') %}

+                       {{ render_admin(admin, form) }}

+                     {% endfor %}

+                 </div>

+               </div>

+             </div>

+           </div>

+         {% endif %}

          <div class="tab-pane fade" id="nav-email" role="tabpanel" aria-labelledby="nav-email-tab">

            <h3 class="font-weight-bold mb-3">

              Email Addresses

              <a class="btn btn-outline-primary btn-sm float-right" href="{{

-               url_for('ui_ns.add_user_email') }}">

+               url_for('ui_ns.add_user_email', username=user.user) }}">

                  Add Email

               </a>

            </h3>
@@ -124,7 +167,7 @@ 

          <div class="tab-pane fade" id="nav-api" role="tabpanel" aria-labelledby="nav-api-tab">

            <h3 class="font-weight-bold mb-3">

              API Keys

-                 <a href="{{ url_for('ui_ns.add_api_user_token') }}" method="post" class="icon float-right">

+                 <a href="{{ url_for('ui_ns.add_api_user_token', username=user.user) }}" method="post" class="icon float-right">

                  <button class="btn btn-sm btn-outline-primary" type="submit"

                    title="Generate a new API token">

                    Create new API Key
@@ -185,7 +228,7 @@ 

                              {% endif %}

                              {% if not token.expired %}

                              <form action="{{ url_for(

-                                 'ui_ns.revoke_api_user_token', token_id=token.id) }}"

+                                 'ui_ns.revoke_api_user_token', token_id=token.id, username=user.user) }}"

                                method="post" class="icon">

                                <button class="btn btn-outline-danger remote-token-btn" type="submit"

                                    title="Revoke token">
@@ -199,9 +242,9 @@ 

                      {% endif %}

                    {% endfor %}

                    {% endif %}

+             </div>

            </div>

          </div>

-         </div>

  

          {% if config.get('LOCAL_SSH_KEY', True) %}

          <div class="tab-pane fade" id="nav-ssh" role="tabpanel" aria-labelledby="nav-ssh-tab">
@@ -225,7 +268,7 @@ 

                        <form class="pull-xs-right" method="POST"

                          action="{{ url_for(

                              'ui_ns.remove_user_sshkey',

-                             keyid=key.id) }}">

+                             keyid=key.id, username=user.user) }}">

                          <button title="Remove SSH key"

                            class="btn btn-outline-danger delete-sshkey-btn">

                            <i class="fa fa-trash"></i>
@@ -245,7 +288,7 @@ 

                    <strong>Add SSH key</strong>

                  </div>

                  <div class="card-body">

-                   <form action="{{ url_for('ui_ns.add_user_sshkey') }}" method="post">

+ 		  <form action="{{ url_for('ui_ns.add_user_sshkey', username=user.user) }}" method="post">

                      <fieldset class="form-group">

                        <label for="ssh_key"><strong>SSH key</strong></label>

                        <textarea class="form-control" name="ssh_key" id="ssh_key"></textarea>
@@ -270,7 +313,7 @@ 

                </h3>

                <div class="row">

                  <div class="col">

-                   <form action="{{ url_for('ui_ns.update_user_settings') }}" method="post">

+                   <form action="{{ url_for('ui_ns.update_user_settings', username=user.user) }}" method="post">

                      <div class="list-group">

                        {% for key in user.settings | sort %}

                            {% if user.settings[key] in [True, False, 'y'] %}

@@ -8,6 +8,10 @@ 

  {% block userprofile_content %}

  <div class="row mt-4 pb-5">

    <div class="col-12">

+       {% if pagure_admin %}

+         <h4><strong>You are logged in with Pagure Admin permissions !</strong></h4>

+ 	<p>

+       {% endif %}

        {% if owned_repos %}

        <h4><strong>Top {{projectstring(plural=True)}}</strong></h4>

        <div class="row">

file modified
+94 -40
@@ -23,8 +23,9 @@ 

  import pagure.lib.query

  import pagure.forms

  import pagure.ui.filters

+ import pagure.ui.services

  from pagure.config import config as pagure_config

- from pagure.flask_app import _get_user, admin_session_timedout

+ from pagure.flask_app import _get_user, admin_session_timedout, get_other_user

  from pagure.ui import UI_NS

  from pagure.utils import (

      authenticated,
@@ -1119,8 +1120,9 @@ 

  

  @UI_NS.route("/settings/")

  @UI_NS.route("/settings")

+ @UI_NS.route("/settings/<username>")

  @login_required

- def user_settings():

+ def user_settings(username=None):

      """ Update the user settings.

      """

      if admin_session_timedout():
@@ -1128,14 +1130,15 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

  

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

      form = pagure.forms.ConfirmationForm()

      return flask.render_template("user_settings.html", user=user, form=form)

  

  

  @UI_NS.route("/settings/usersettings", methods=["POST"])

+ @UI_NS.route("/settings/usersettings/<username>", methods=["POST"])

  @login_required

- def update_user_settings():

+ def update_user_settings(username=None):

      """ Update the user's settings set in the settings page.

      """

      if admin_session_timedout():
@@ -1145,7 +1148,7 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

  

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

  

      form = pagure.forms.ConfirmationForm()

  
@@ -1169,12 +1172,15 @@ 

              flask.g.session.rollback()

              flask.flash(str(err), "error")

  

-     return flask.redirect(flask.url_for("ui_ns.user_settings"))

+     return flask.redirect(

+         flask.url_for("ui_ns.user_settings") + "/" + user.user

+     )

  

  

  @UI_NS.route("/settings/usersettings/addkey", methods=["POST"])

+ @UI_NS.route("/settings/usersettings/<username>/addkey", methods=["POST"])

  @login_required

- def add_user_sshkey():

+ def add_user_sshkey(username=None):

      """ Add the specified SSH key to the user.

      """

      if admin_session_timedout():
@@ -1186,8 +1192,8 @@ 

  

      form = pagure.forms.AddSSHKeyForm()

  

+     user = get_other_user(username)

      if form.validate_on_submit():

-         user = _get_user(username=flask.g.fas_user.username)

          try:

              msg = pagure.lib.query.add_sshkey_to_project_or_user(

                  flask.g.session,
@@ -1203,7 +1209,10 @@ 

              pagure.lib.tasks.gitolite_post_compile_only.delay()

              flask.flash(msg)

              return flask.redirect(

-                 flask.url_for("ui_ns.user_settings") + "#nav-ssh-tab"

+                 flask.url_for("ui_ns.user_settings")

+                 + "/"

+                 + user.user

+                 + "#nav-ssh-tab"

              )

          except pagure.exceptions.PagureException as msg:

              flask.g.session.rollback()
@@ -1214,14 +1223,21 @@ 

              _log.exception(err)

              flask.flash("SSH key could not be added", "error")

  

+     userstring = user.user or ""

      return flask.redirect(

-         flask.url_for("ui_ns.user_settings") + "#nav-ssh-tab"

+         flask.url_for("ui_ns.user_settings")

+         + "/"

+         + userstring

+         + "#nav-ssh-tab"

      )

  

  

  @UI_NS.route("/settings/usersettings/removekey/<int:keyid>", methods=["POST"])

+ @UI_NS.route(

+     "/settings/usersettings/removekey/<int:keyid>/<username>", methods=["POST"]

+ )

  @login_required

- def remove_user_sshkey(keyid):

+ def remove_user_sshkey(keyid, username=None):

      """ Removes an SSH key from the user.

      """

      if admin_session_timedout():
@@ -1231,8 +1247,8 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

      form = pagure.forms.ConfirmationForm()

+     user = get_other_user(username)

      if form.validate_on_submit():

-         user = _get_user(username=flask.g.fas_user.username)

          found = False

          for key in user.sshkeys:

              if key.id == keyid:
@@ -1243,7 +1259,10 @@ 

          if not found:

              flask.flash("SSH key does not exist in user.", "error")

              return flask.redirect(

-                 flask.url_for("ui_ns.user_settings") + "#nav-ssh-tab"

+                 flask.url_for("ui_ns.user_settings")

+                 + "/"

+                 + user.user

+                 + "#nav-ssh-tab"

              )

  

          try:
@@ -1259,7 +1278,7 @@ 

              flask.flash("SSH key could not be removed", "error")

  

      return flask.redirect(

-         flask.url_for("ui_ns.user_settings") + "#nav-ssh-tab"

+         flask.url_for("ui_ns.user_settings") + "/" + user.user + "#nav-ssh-tab"

      )

  

  
@@ -1277,8 +1296,9 @@ 

  

  

  @UI_NS.route("/settings/email/drop", methods=["POST"])

+ @UI_NS.route("/settings/<username>/email/drop", methods=["POST"])

  @login_required

- def remove_user_email():

+ def remove_user_email(username=None):

      """ Remove the specified email from the logged in user.

      """

      if admin_session_timedout():
@@ -1286,11 +1306,13 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

  

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

  

      if len(user.emails) == 1:

          flask.flash("You must always have at least one email", "error")

-         return flask.redirect(flask.url_for("ui_ns.user_settings"))

+         return flask.redirect(

+             flask.url_for("ui_ns.user_settings") + "/" + user.user

+         )

  

      form = pagure.forms.UserEmailForm()

  
@@ -1303,7 +1325,9 @@ 

                  "You do not have the email: %s, nothing to remove" % email,

                  "error",

              )

-             return flask.redirect(flask.url_for("ui_ns.user_settings"))

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

  

          for mail in user.emails:

              if mail.email == email:
@@ -1317,13 +1341,16 @@ 

              _log.exception(err)

              flask.flash("Email could not be removed", "error")

  

-     return flask.redirect(flask.url_for("ui_ns.user_settings"))

+     return flask.redirect(

+         flask.url_for("ui_ns.user_settings") + "/" + user.user

+     )

  

  

  @UI_NS.route("/settings/email/add/", methods=["GET", "POST"])

  @UI_NS.route("/settings/email/add", methods=["GET", "POST"])

+ @UI_NS.route("/settings/email/add/<username>", methods=["GET", "POST"])

  @login_required

- def add_user_email():

+ def add_user_email(username=None):

      """ Add a new email for the logged in user.

      """

      if admin_session_timedout():
@@ -1331,7 +1358,7 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

  

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

  

      form = pagure.forms.UserEmailForm(

          emails=[mail.email for mail in user.emails]
@@ -1357,8 +1384,9 @@ 

  

  

  @UI_NS.route("/settings/email/default", methods=["POST"])

+ @UI_NS.route("/settings/<username>/email/default", methods=["POST"])

  @login_required

- def set_default_email():

+ def set_default_email(username=None):

      """ Set the default email address of the user.

      """

      if admin_session_timedout():
@@ -1366,7 +1394,7 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

  

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

  

      form = pagure.forms.UserEmailForm()

      if form.validate_on_submit():
@@ -1379,7 +1407,9 @@ 

                  "error",

              )

  

-             return flask.redirect(flask.url_for("ui_ns.user_settings"))

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

  

          user.default_email = email

  
@@ -1391,12 +1421,15 @@ 

              _log.exception(err)

              flask.flash("Default email could not be set", "error")

  

-     return flask.redirect(flask.url_for("ui_ns.user_settings"))

+     return flask.redirect(

+         flask.url_for("ui_ns.user_settings") + "/" + user.user

+     )

  

  

  @UI_NS.route("/settings/email/resend", methods=["POST"])

+ @UI_NS.route("/settings/<username>/email/resend", methods=["POST"])

  @login_required

- def reconfirm_email():

+ def reconfirm_email(username=None):

      """ Re-send the email address of the user.

      """

      if admin_session_timedout():
@@ -1404,7 +1437,7 @@ 

              flask.url_for("auth_login", next=flask.request.url)

          )

  

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

  

      form = pagure.forms.UserEmailForm()

      if form.validate_on_submit():
@@ -1421,12 +1454,16 @@ 

              _log.exception(err)

              flask.flash("Confirmation email could not be re-sent", "error")

  

-     return flask.redirect(flask.url_for("ui_ns.user_settings"))

+     return flask.redirect(

+         flask.url_for("ui_ns.user_settings") + "/" + user.user

+     )

  

  

  @UI_NS.route("/settings/email/confirm/<token>/")

  @UI_NS.route("/settings/email/confirm/<token>")

- def confirm_email(token):

+ @UI_NS.route("/settings/<username>/email/confirm/<token>/")

+ @UI_NS.route("/settings/<username>/email/confirm/<token>")

+ def confirm_email(token, username=None):

      """ Confirm a new email.

      """

      if admin_session_timedout():
@@ -1437,6 +1474,7 @@ 

      email = pagure.lib.query.search_pending_email(flask.g.session, token=token)

      if not email:

          flask.flash("No email associated with this token.", "error")

+         return flask.redirect(flask.url_for("ui_ns.user_settings"))

      else:

          try:

              pagure.lib.query.add_email_to_user(
@@ -1456,8 +1494,9 @@ 

                  "error",

              )

              _log.exception(err)

- 

-     return flask.redirect(flask.url_for("ui_ns.user_settings"))

+         return flask.redirect(

+             flask.url_for("ui_ns.user_settings") + "/" + email.user.username

+         )

  

  

  @UI_NS.route("/ssh_info/")
@@ -1469,10 +1508,12 @@ 

      return flask.render_template("doc_ssh_keys.html")

  

  

- @UI_NS.route("/settings/token/new/", methods=("GET", "POST"))

+ # UI_NS.route("/settings/token/new/", methods=("GET", "POST"))

+ # UI_NS.route("/settings/token/new", methods=("GET", "POST"))

  @UI_NS.route("/settings/token/new", methods=("GET", "POST"))

+ @UI_NS.route("/settings/<username>/token/new", methods=("GET", "POST"))

  @login_required

- def add_api_user_token():

+ def add_api_user_token(username=None):

      """ Create an user token (not project specific).

      """

      if admin_session_timedout():
@@ -1483,7 +1524,7 @@ 

          )

  

      # Ensure the user is in the DB at least

-     user = _get_user(username=flask.g.fas_user.username)

+     user = get_other_user(username)

  

      acls = pagure.lib.query.get_acls(

          flask.g.session, restrict=pagure_config.get("CROSS_PROJECT_ACLS")
@@ -1502,7 +1543,10 @@ 

              flask.g.session.commit()

              flask.flash("Token created")

              return flask.redirect(

-                 flask.url_for("ui_ns.user_settings") + "#nav-api-tab"

+                 flask.url_for("ui_ns.user_settings")

+                 + "/"

+                 + user.user

+                 + "#nav-api-tab"

              )

          except SQLAlchemyError as err:  # pragma: no cover

              flask.g.session.rollback()
@@ -1514,14 +1558,19 @@ 

          flask.flash("You must select at least one permission.", "error")

  

      return flask.render_template(

-         "add_token.html", select="settings", form=form, acls=acls

+         "add_token.html",

+         select="settings",

+         form=form,

+         acls=acls,

+         username=user.username,

      )

  

  

  @UI_NS.route("/settings/token/revoke/<token_id>/", methods=["POST"])

  @UI_NS.route("/settings/token/revoke/<token_id>", methods=["POST"])

+ @UI_NS.route("/settings/token/revoke/<token_id>/<username>", methods=["POST"])

  @login_required

- def revoke_api_user_token(token_id):

+ def revoke_api_user_token(token_id, username=None):

      """ Revoke a user token (ie: not project specific).

      """

      if admin_session_timedout():
@@ -1531,8 +1580,10 @@ 

  

      token = pagure.lib.query.get_api_token(flask.g.session, token_id)

  

-     if not token or token.user.username != flask.g.fas_user.username:

-         flask.abort(404, description="Token not found")

+     user = get_other_user(username)

+ 

+     if not token or token.user.username != user.username:

+         flask.abort(404, "Token not found")

  

      form = pagure.forms.ConfirmationForm()

  
@@ -1551,7 +1602,10 @@ 

              )

  

      return flask.redirect(

-         flask.url_for("ui_ns.user_settings") + "#nav-api-token"

+         flask.url_for("ui_ns.user_settings")

+         + "/"

+         + user.user

+         + "#nav-api-token"

      )

  

  

file added
+258
@@ -0,0 +1,258 @@ 

+ # -*- coding: utf-8 -*-

+ 

+ """

+  (c) 2014-2019 - Copyright Red Hat Inc

+ 

+  Authors:

+    Karsten Hopp <karsten@redhat.com

+ 

+ """

+ 

+ from __future__ import unicode_literals

+ 

+ import logging

+ 

+ import flask

+ from sqlalchemy.exc import SQLAlchemyError

+ 

+ import pagure.login_forms as forms

+ import pagure.lib.login

+ import pagure.lib.model as model

+ import pagure.lib.notify

+ import pagure.lib.query

+ from pagure.ui import UI_NS

+ from pagure.config import config as pagure_config

+ from pagure.flask_app import admin_session_timedout, get_other_user

+ from pagure.utils import login_required

+ 

+ 

+ _log = logging.getLogger(__name__)

+ 

+ 

+ @UI_NS.route("/user/new/service/", methods=["GET", "POST"])

+ @UI_NS.route("/user/new/service", methods=["GET", "POST"])

+ def new_service_account():

+     """ Create a new service account.

+     """

+     if admin_session_timedout():

+         return flask.redirect(

+             flask.url_for("auth_login", next=flask.request.url)

+         )

+ 

+     if not pagure.utils.is_admin():

+         flask.flash("Restricted to pagure admins", "error")

+         return flask.redirect(flask.request.url)

+     form = forms.NewServiceForm()

+     if form.validate_on_submit():

+ 

+         username = form.user.data

+         if username[:8] != "service:":

+             flask.flash(

+                 "Service account names need to start with 'service:'", "error"

+             )

+             flask.flash("For example 'service:" + username + "'", "error")

+             return flask.redirect(flask.request.url)

+ 

+         if pagure.lib.query.search_user(flask.g.session, username=username):

+             flask.flash("Service account name already taken.", "error")

+             return flask.redirect(flask.request.url)

+ 

+         token = pagure.lib.login.id_generator(40)

+ 

+         user = model.User()

+         user.token = token

+         servicemaintainers = []

+         emails = []

+         for data in form.service_admins.data.split(","):

+             data = data.strip()

+             user_obj = pagure.lib.query.search_user(

+                 flask.g.session, username=data

+             )

+             if not user_obj:

+                 flask.flash("User " + data + " does not exist.", "error")

+                 _log.exception("User " + data + " does not exist.")

+                 return flask.redirect(

+                     flask.url_for("ui_ns.new_service_account")

+                 )

+             else:

+                 servicemaintainers.append(data)

+                 emails.append(user_obj.default_email)

+         user.servicemaintainers = ",".join(servicemaintainers)

+         user.default_email = ",".join(emails)

+         user.is_service = True

+         form.populate_obj(obj=user)

+         flask.g.session.add(user)

+         flask.g.session.flush()

+ 

+         try:

+             flask.g.session.commit()

+         except pagure.exceptions.PagureException as err:

+             flask.flash(str(err), "error")

+             _log.exception(err)

+         except SQLAlchemyError:  # pragma: no cover

+             flask.g.session.rollback()

+             flask.flash("Could not create service account.")

+             _log.exception("Could not create service account.")

+ 

+         pagure.lib.query.create_user_ssh_keys_on_disk(

+             user, pagure_config.get("GITOLITE_KEYDIR", None)

+         )

+         return flask.redirect(

+             flask.url_for(

+                 "ui_ns.manage_services", username=flask.g.fas_user.username

+             )

+         )

+ 

+     return flask.render_template("login/service_account_new.html", form=form)

+ 

+ 

+ @UI_NS.route("/settings/<username>/serviceadmin/add", methods=["GET", "POST"])

+ @login_required

+ def add_service_admin(username=None):

+     """add another user as serviceadmin to this service account.

+     """

+     if admin_session_timedout():

+         return flask.redirect(

+             flask.url_for("auth_login", next=flask.request.url)

+         )

+ 

+     user = get_other_user(username)

+ 

+     form = pagure.forms.ServiceAdminForm(

+         admins=user.servicemaintainers.split(",")

+     )

+     if form.validate_on_submit():

+         newadmin = form.admin.data

+         serviceadmins = user.servicemaintainers.split(",")

+         if newadmin in serviceadmins:

+             flask.flash(

+                 "'%s' is already an admin of the '%s' service account"

+                 % (newadmin, username),

+                 "error",

+             )

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

+         user_obj = pagure.lib.query.search_user(

+             flask.g.session, username=newadmin

+         )

+         if not user_obj:

+             flask.flash("User " + newadmin + " does not exist.", "error")

+             _log.exception("User " + newadmin + " does not exist.")

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

+         serviceadmins.append(newadmin)

+         user.default_email = user.default_email.join(

+             ",%s" % user_obj.default_email

+         )

+         user.servicemaintainers = ",".join(serviceadmins)

+         try:

+             flask.g.session.commit()

+             flask.flash("Admin added")

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

+         except SQLAlchemyError as err:  # pragma: no cover

+             flask.g.session.rollback()

+             _log.exception(err)

+             flask.flash("Admin could not be added", "error")

+ 

+     return flask.render_template(

+         "service_admins.html", username=username, form=form

+     )

+ 

+ 

+ @UI_NS.route(

+     "/settings/<username>/serviceadmin/remove/<admin>", methods=["GET", "POST"]

+ )

+ @login_required

+ def remove_service_admin(username=None, admin=None):

+     """ Remove the specified admin from the service account.

+     """

+     if admin_session_timedout():

+         return flask.redirect(

+             flask.url_for("auth_login", next=flask.request.url)

+         )

+ 

+     user = get_other_user(username)

+ 

+     if len(user.servicemaintainers.split(",")) == 1:

+         flask.flash("You must always have at least one admin", "error")

+         return flask.redirect(

+             flask.url_for("ui_ns.user_settings") + "/" + user.user

+         )

+ 

+     form = pagure.forms.ServiceAdminForm()

+ 

+     if form.validate_on_submit():

+         if not user.is_service:

+             flask.flash(

+                 "Account '%s' is not a service account" % username, "error"

+             )

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

+         admin = form.admin.data

+         serviceadmins = user.servicemaintainers.split(",")

+ 

+         if admin not in serviceadmins:

+             flask.flash(

+                 "'%s' is not an admin of the '%s' service account"

+                 % (admin, username),

+                 "error",

+             )

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + user.user

+             )

+ 

+         serviceadmins.remove(admin)

+         user_obj = pagure.lib.query.search_user(

+             flask.g.session, username=admin

+         )

+         if user_obj:

+             rmadmin_emails = set(user_obj.default_email.split(","))

+             admin_emails = user.default_email.split(",")

+             admin_emails = [x for x in admin_emails if x not in rmadmin_emails]

+             user.default_email = ",".join(admin_emails)

+         user.servicemaintainers = ",".join(serviceadmins)

+         try:

+             flask.g.session.commit()

+             flask.flash("Admin %s removed" % admin)

+         except SQLAlchemyError as err:  # pragma: no cover

+             flask.g.session.rollback()

+             _log.exception(err)

+             flask.flash("Admin %s could not be removed" % admin, "error")

+ 

+     return flask.redirect(

+         flask.url_for("ui_ns.user_settings") + "/" + user.user

+     )

+ 

+ 

+ @UI_NS.route("/user/<username>/services/", methods=["GET", "POST"])

+ @UI_NS.route("/user/<username>/services", methods=["GET", "POST"])

+ def manage_services(username=None):

+     """ List, edit or create service accounts.

+     """

+     if admin_session_timedout():

+         return flask.redirect(

+             flask.url_for("auth_login", next=flask.request.url)

+         )

+ 

+     allowed_services = []

+     users = pagure.lib.query.search_user(flask.g.session, is_service=True)

+     if not pagure.utils.is_admin():

+         for service in users:

+             if username in service.servicemaintainers.split(","):

+                 allowed_services.append(service)

+         if not allowed_services:

+             flask.flash("Restricted to pagure admins", "error")

+             return flask.redirect(

+                 flask.url_for("ui_ns.user_settings") + "/" + username

+             )

+         else:

+             users = allowed_services

+ 

+     return flask.render_template(

+         "service_accounts.html", service_accounts=users

+     )

file modified
+1
@@ -594,6 +594,7 @@ 

          self.name = username

          self.email = "foo@bar.com"

          self.default_email = "foo@bar.com"

+         self.is_service = False

  

          self.approved_memberships = [

              FakeGroup("packager"),

@@ -1152,15 +1152,21 @@ 

  

          user = tests.FakeUser(username="pingou")

          with tests.user_set(self.app.application, user):

-             output = self.app.get("/settings", follow_redirects=True)

+             output = self.app.get(

+                 "/settings/pingou", follow_redirects=True

+             )

+             self.assertEqual(output.status_code, 200)

+             pagure.config.config["ENABLE_SERVICE_ACCOUNTS"] = True

+             output = self.app.get("/settings/pingou", follow_redirects=True)

              self.assertEqual(output.status_code, 200)

              output_text = output.get_data(as_text=True)

              self.assertIn("<strong>Add SSH key", output_text)

  

              csrf_token = self.get_csrf(output=output)

  

-             data = {"ssh_key": "asdf"}

+             data = {"ssh_key": "asdf", "username": user.user}

  

+             pagure.config.config["ENABLE_SERVICE_ACCOUNTS"] = False

              # No CSRF token

              output = self.app.post(

                  "/settings/usersettings/addkey",
@@ -1172,6 +1178,7 @@ 

              self.assertIn("<strong>Add SSH key", output_text)

  

              data["csrf_token"] = csrf_token

+             data["username"] = user.user

  

              # First, invalid SSH key

              output = self.app.post(
@@ -1201,6 +1208,7 @@ 

              data[

                  "ssh_key"

              ] = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDAzBMSIlvPRaEiLOTVInErkRIw9CzQQcnslDekAn1jFnGf+SNa1acvbTiATbCX71AA03giKrPxPH79dxcC7aDXerc6zRcKjJs6MAL9PrCjnbyxCKXRNNZU5U9X/DLaaL1b3caB+WD6OoorhS3LTEtKPX8xyjOzhf3OQSzNjhJp5Q=="

+             data["username"] = "pingou"

              output = self.app.post(

                  "/settings/usersettings/addkey",

                  data=data,
@@ -1252,7 +1260,7 @@ 

  

          user.username = "pingou"

          with tests.user_set(self.app.application, user):

-             data = {"csrf_token": self.get_csrf()}

+             data = {"csrf_token": self.get_csrf(), "username": user.user}

  

              output = self.app.post(

                  "/settings/usersettings/removekey/1",
@@ -1331,8 +1339,9 @@ 

  

          user = tests.FakeUser()

          user.username = "foo"

+         data["username"] = user.user

          with tests.user_set(self.app.application, user):

-             output = self.app.get("/settings/")

+             output = self.app.get("/settings/", data=data)

              self.assertEqual(output.status_code, 200)

              output_text = output.get_data(as_text=True)

              self.assertIn(
@@ -1853,6 +1862,8 @@ 

      @patch("pagure.ui.app.admin_session_timedout")

      def test_confirm_email(self, ast):

          """ Test the confirm_email endpoint. """

+         output = self.app.get("/settings/pingou/email/confirm/foobar")

+         self.assertEqual(output.status_code, 302)

          output = self.app.get("/settings/email/confirm/foobar")

          self.assertEqual(output.status_code, 302)

  
@@ -1874,6 +1885,16 @@ 

          with tests.user_set(self.app.application, user):

              # Wrong token

              output = self.app.get(

+                 "/settings/pingou/email/confirm/foobar", follow_redirects=True

+             )

+             self.assertEqual(output.status_code, 200)

+             output_text = output.get_data(as_text=True)

+             self.assertIn(

+                 "<title>pingou's settings - Pagure</title>", output_text

+             )

+             self.assertIn("No email associated with this token.", output_text)

+             # Wrong token

+             output = self.app.get(

                  "/settings/email/confirm/foobar", follow_redirects=True

              )

              self.assertEqual(output.status_code, 200)

Add service accounts

Service accounts are just like normal user accounts, but with a longer expiration time for tokens (TODO: make that time configurable) and they can be maintained by a number of assigned service admins once they got created by a pagure admin.

This patch adds functionality to edit other user preferences. Normal users can't edit other user preferences, service admins can only edit their assigned service accounts. Nice side effect is that pagure admins can now edit other user accounts.

Signed-off-by: Karsten Hopp karsten@redhat.com

This doesn't seem to be used anywhere :)

could we reformulate the docstring, from just reading it, I don't understand what it does :(

This could be moved outside of the if, allowing to drop the else entirely

If username is optional, I'd recommend we give it a default value (username=None)

I'm confused, does this means these templates can be accessed even if the service accounts are disabled?

if we always activate this file, I'd recommend we add a decorator that simply returns a 404 if the pagure instance isn't configured for service accounts.

Service accounts are just like normal user accounts, but with a longer expiration time for tokens

We ought to formulate this differently, otherwise, isn't the solution to simply increase the expiration time for API tokens? (which pagure admins can already do manually using the pagure-admin CLI).

rebased onto 14ab2530101b910182cba8d4fb86f7925d5f8774

4 years ago

I've
- removed unused functions in pagure/lib/model.py
- updated the docstring of get_other_user()
- moved 'name = flask.g.fas_user.username' in pagure/flask_app.py
to get rid of an else: statement
- set default username to None in get_other_user()
- update year in copyright of pagure/ui/services.py

regarding the check for ENABLE_SERVICE_ACCOUNTS. If you know that there is an URL http://localhost.localdomain:5000/user/new/service/ it is better to give a hint about what setting needs to be added/changed instead of returning 404. Just my opinion, but I can fix the code if you insist.

Pretty please pagure-ci rebuild

rebased onto c5e1f40af74c490eb66d39edc20c2b82dbd99bfd

4 years ago

2 new commits added

  • add decorator without username
  • flake8 and black fixes
4 years ago

rebased onto 1abbe7ddfce114724977894c61251d4faa87f64b

4 years ago

rebased onto a8c3c29fd77dde1d8964cdfdf2a5c39dd39166e2

4 years ago

rebased onto 8126b1b

4 years ago