Basic SVN management web UI works.
authorSybren A. Stüvel <sybren@stuvel.eu>
Thu, 9 Nov 2017 15:26:47 +0000 (16:26 +0100)
committerSybren A. Stüvel <sybren@stuvel.eu>
Thu, 9 Nov 2017 15:26:47 +0000 (16:26 +0100)
src/scripts/tutti/00_utils.js
src/styles/main.sass [deleted file]
src/styles/svnman.sass [new file with mode: 0644]
src/templates/svnman/project_settings/settings.pug
src/templates/svnman/project_settings/svnman_layout.pug
svnman/__init__.py
svnman/remote.py
svnman/routes.py

index 7033f41..c1fe574 100644 (file)
@@ -1,7 +1,13 @@
 
 /* Returns a more-or-less reasonable message given an error response object. */
 function xhrErrorResponseMessage(err) {
-    console.log(err);
+    if (err.readyState == 0 || err.status == 0)
+        return "Connection refused; either the server is unreachable or your internet connection is down.";
+
+    if (err.status == 451) {
+        return "This user has no Subversion usage in their subscription package.";
+    }
+
     if (typeof err.responseJSON == 'undefined')
         return err.statusText;
 
@@ -19,3 +25,38 @@ function xhrErrorResponseElement(err, prefix) {
     return $('<span>')
         .text(prefix + msg);
 }
+
+var Password = {
+    _pattern: /[a-hj-np-zA-HJ-NP-Z2-9_\-\+'?!]/,
+
+    _getRandomByte: function() {
+        // http://caniuse.com/#feat=getrandomvalues
+        if (window.crypto && window.crypto.getRandomValues) {
+            var result = new Uint8Array(1);
+            window.crypto.getRandomValues(result);
+            return result[0];
+        } else if (window.msCrypto && window.msCrypto.getRandomValues) {
+            var result = new Uint8Array(1);
+            window.msCrypto.getRandomValues(result);
+            return result[0];
+        } else {
+            return Math.floor(Math.random() * 256);
+        }
+    },
+
+    generate: function(length) {
+        return Array.apply(null, {
+                'length': length
+            })
+            .map(function() {
+                var result;
+                while (true) {
+                    result = String.fromCharCode(this._getRandomByte());
+                    if (this._pattern.test(result)) {
+                        return result;
+                    }
+                }
+            }, this)
+            .join('');
+    }
+};
diff --git a/src/styles/main.sass b/src/styles/main.sass
deleted file mode 100644 (file)
index 27ec247..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-/* Import basic styling and config from Pillar */
-@import ../../../pillar/src/styles/_config
-@import ../../../pillar/src/styles/_utils
-@import ../../../pillar/src/styles/base
-@import ../../../pillar/src/styles/_project-sharing
diff --git a/src/styles/svnman.sass b/src/styles/svnman.sass
new file mode 100644 (file)
index 0000000..1e41d06
--- /dev/null
@@ -0,0 +1,13 @@
+@import ../../../pillar/src/styles/_config
+
+button.user-remove
+       background: none
+       border: none
+
+       color: $color-danger
+
+       &:hover
+               color: lighten($color-danger, 10%)
+
+.copy-to-clipboard
+       cursor: pointer
index 026fcbb..a240461 100644 (file)
@@ -1,66 +1,89 @@
 | {% extends 'svnman/project_settings/svnman_layout.html'  %}
 
+| {% block head %}
+| {{ super() }}
+style.
+       section {
+               margin-bottom: 2.5em;
+       }
+       .access-users-list td {
+               padding: 0 0.2em;
+       }
+       .access-users-list tr.self td {
+               font-weight: bold;
+       }
+| {% endblock head %}
+
 | {% block svnman_container %}
 #node-edit-form
-       #node-edit-form
-               p This project has a Subversion repository
-               p To do a checkout, use:
+       section
+               h4 Using the repository
+
+               p This project has a Subversion repository. To do a checkout, use:
                p
                        code svn checkout {{ svn_url }} my_repo
-               p
-                       button.btn.btn-danger(onclick='deleteRepo()') Delete Subversion repository
 
-               h4 Repository Access
-
-               .row
-                       .col-md-6
-                               .access-users-search
-                                       .form-group
-                                               input#user-search.form-control(
-                                                       name='contacts',
-                                                       type='text',
-                                                       placeholder='Grant user access by name')
-
-                               ul.access-users-list
-                                       | {% for user in svn_users %}
-                                       li.access-users-item(
-                                               data-user-id="{{ user['_id'] }}",
-                                               class="{% if current_user.objectid == user['_id'] %}self{% endif %}")
-                                               .access-users-avatar
-                                                       img(src="{{ user['avatar'] }}")
-                                               .access-users-details
-                                                       span.access-users-name
-                                                               | {{ user['full_name'] }}
-                                                               | {% if current_user.objectid == user['_id'] %}
-                                                               small (You)
-                                                               | {% endif %}
-                                                       span.access-users-extra {{ user['username'] }}
-                                               .access-users-action
-                                                       | {# Only allow deletion if we are: admin, project users, or current_user in the team #}
-                                                       | {% if can_abandon_manager %}
-                                                       | {%     if current_user.objectid == user['_id'] %}
-                                                       button.user-remove(title="Revoke your own access")
-                                                               i.pi-trash
-                                                       | {%     else %}
-                                                       button.user-remove(title="Revoke access of this user")
-                                                               i.pi-trash
-                                                       | {%     endif %}
-                                                       | {% endif %}
+       section
+               h4 Manage Access
+               p.
+                       Users in this list have read/write access to the Subversion repository. It is not
+                       possible to allow read-only access. Their password is <em>not</em> their Blender ID
+                       password, but has to be set separately.
+               p.
+                       Your login for Subversion is <a href='{{ url_for('settings.profile') }}'>your username</a>
+                       as it was at the moment you were granted access to the repository.
 
-                                       | {% endfor %}
+               .access-users-search
+                       .form-group
+                               input#user-search.form-control(
+                                       name='contacts',
+                                       type='text',
+                                       placeholder='Grant user access by name')
 
-                       .col-md-6
-                               .access-users-info
-                                       h4 What can these users do?
-                                       p.
-                                               Users in this list have read/write access to the Subversion repository. It
-                                               is not possible to allow read-only access.
-                                       p.
-                                               Their password is <em>not</em> their Blender ID password, but has to be set
-                                               separately.
+               table.access-users-list
+                       tbody
+                               tr
+                                       th
+                                       th Subversion Login
+                                       th Password set?
+                                       th
 
+                               | {% for userinfo in svn_users %}
+                               | {% set user=userinfo['db'] %}
+                               | {% set is_self=current_user.user_id == user['_id'] %}
+                               tr(data-user-id="{{ user['_id'] }}",
+                                       class="{% if is_self %}self{% endif %}")
+                                       td
+                                               img.access-users-avatar(src="{{ user['email'] | gravatar(24) }}")
+                                               span.access-users-name
+                                                       | {{ user['full_name'] }}
+                                                       | {% if current_user.objectid == user['_id'] %}
+                                                       small (You)
+                                                       | {% endif %}
+                                       td.copy-to-clipboard(data-clipboard-text="{{ userinfo['username'] }}") {{ userinfo['username'] }}
+                                       td.col-password
+                                               | {% if userinfo['pw_set'] %}
+                                               button.btn.btn-default(title="{% if is_self %}You have{% else %}The user has{% endif %} set a password; click to change it.") Change password
+                                               | {% else %}
+                                               i.pi-cancel
+                                               button.btn.btn-warning(title="{% if is_self %}You have{% else %}The user has{% endif %} no password; click to set it.") Set password
+                                               | {% endif %}
+                                       td.col-revoke
+                                               | {%     if is_self %}
+                                               button.user-remove(title="Revoke your own access")
+                                                       i.pi-trash
+                                               | {%     else %}
+                                               button.user-remove(title="Revoke access of this user")
+                                                       i.pi-trash
+                                               | {%     endif %}
 
+                       | {% endfor %}
 
+       section
+               h4 Dangerous operations
+               p
+                       button.btn.btn-danger(onclick='deleteRepo()') Delete Subversion repository
+               p Note that deleting an operation is permanent and <em>cannot be undone</em>. Use with caution.
 
 | {% endblock svnman_container %}
 
@@ -70,6 +93,10 @@ script.
        var algolia_public_key = '{{config.ALGOLIA_PUBLIC_KEY}}';
        var algolia_index_users = '{{config.ALGOLIA_INDEX_USERS}}';
 
+       var grant_access_url = '{{ url_for( "svnman.grant_access", project_url=project.url, repo_id=repo_id) }}';
+       var revoke_access_url = '{{ url_for( "svnman.revoke_access", project_url=project.url, repo_id=repo_id) }}';
+       var delete_repo_url = '{{ url_for( "svnman.delete_repo", project_url=project.url, repo_id=repo_id) }}';
+
        $('#user-search').userSearch(algolia_application_id, algolia_public_key, algolia_index_users,
                function(event, hit, dataset) {
                        var $existing = $('li.access-users-item[data-user-id="' + hit.objectID + '"]');
@@ -90,40 +117,71 @@ script.
                }
        );
 
+       $('.access-users-list .col-password button').click(function() {
+               var user_id = $(this).closest('*[data-user-id]').data('user-id');
+               setPassword(user_id);
+       })
+
+       $('.access-users-list .col-revoke button').click(function() {
+               var user_id = $(this).closest('*[data-user-id]').data('user-id');
+               revokeUser(user_id);
+       })
+
+       function setPassword(user_id) {
+               var randomstring = Password.generate(16);
+
+               password = prompt("Provide a new password. We have generated a random one for you. " +
+                       "It's up to you to send this password to the user in a secure way.", randomstring);
+               if (!password) return;
+
+               ajax(grant_access_url, {user_id: user_id, password: password});
+       }
+
        function grantUser(user_id) {
-               console.log('grant to', user_id);
-               $.ajax({
-                       url: '{{ url_for( "svnman.grant_access", project_url=project.url, repo_id=repo_id) }}',
-                       data: {user_id: user_id},
-                       method: 'POST',
-               })
-               .done(function() {
-                       window.location.reload();
-               })
-               .fail(function(err) {
-                       if (err.status == 451) {
-                               toastr.error("This user has no Subversion usage in their subscription package.");
-                               return;
-                       }
-                       var err_elt = xhrErrorResponseElement(err, 'Error granting access: ');
-                       toastr.error(err_elt);
-               });
+               toastr.info('Granting access to user')
+               ajax(grant_access_url, {user_id: user_id});
+       }
+
+       function revokeUser(user_id) {
+               toastr.info('Revoking access from user')
+               ajax(revoke_access_url, {user_id: user_id});
        }
 
        function deleteRepo() {
                if (!confirm('Are you sure you want to delete this repository? This CANNOT be undone! You WILL loose this data.'))
                        return;
+               toastr.info('Deleting repository')
+               ajax(delete_repo_url);
+       }
 
+       function ajax(url, payload) {
                $.ajax({
-                       url: '{{ url_for( "svnman.delete_repo", project_url=project.url, repo_id=repo_id) }}',
+                       url: url,
+                       data: payload,
                        method: 'POST',
                })
                .done(function() {
                        window.location.reload();
                })
                .fail(function(err) {
-                       var err_elt = xhrErrorResponseElement(err, 'Error deleting your repository: ');
+                       var err_elt = xhrErrorResponseElement(err, 'Error granting access: ');
                        toastr.error(err_elt);
                });
        }
+
+       var clipboard = null;
+       function createClipboard() {
+               if (clipboard != null) {
+                       clipboard.destroy();
+               }
+
+               clipboard = new Clipboard('.copy-to-clipboard');
+
+               clipboard.on('success', function(e) {
+                       $(e.trigger).flashOnce();
+                       toastr.success('Copied to clipboard');
+               });
+       }
+       createClipboard();
+
 | {% endblock %}
index f184fd5..ebb9089 100644 (file)
@@ -3,8 +3,10 @@
 | {% block page_title %}Subversion settings for {{ project.name }}{% endblock %}
 
 | {% block head %}
+link(href="{{ url_for('static_svnman', filename='assets/css/svnman.css') }}", rel='stylesheet')
 script(src="{{ url_for('static_svnman', filename='assets/js/generated/tutti.min.js') }}")
 script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.autocomplete-0.22.0.min.js') }}")
+script(src="{{ url_for('static_pillar', filename='assets/js/vendor/clipboard.min.js')}}")
 | {% endblock %}
 
 | {% block project_context_header %}
index 55c5924..7a25fb4 100644 (file)
@@ -16,11 +16,13 @@ from pillar.api.utils.authorization import require_login
 from pillar import current_app
 
 EXTENSION_NAME = 'svnman'
+UNSET_PASSWORD = '$2y$1$password-empty'
 
 
 # SVNman stores the following keys in the project extension properties:
 # repo_id: the Subversion repository ID
-# users: list of ObjectIDs of users having access to the project
+# users: dict of users having access to the project:
+#        {'user_id_as_str': {'username': 'uname-on-svn', 'pw_is_set': bool}}
 
 class SVNManExtension(PillarExtension):
     user_caps = {
@@ -123,20 +125,42 @@ class SVNManExtension(PillarExtension):
 
         from .routes import project_settings
 
-        remote_url = current_app.config['SVNMAN_REPO_URL']
+        if not self.is_svnman_project(project):
+            return flask.render_template('svnman/project_settings/offer_create_repo.html',
+                                         project=project, **template_args)
 
-        if self.is_svnman_project(project):
-            repo_id = project.extension_props[EXTENSION_NAME].repo_id
-            svn_url = urljoin(remote_url, repo_id)
+        remote_url = current_app.config['SVNMAN_REPO_URL']
+        users_coll = current_app.db('users')
+
+        # list of {'username': 'uname-on-svn', 'db': user in our DB, 'pw_is_set': bool} dicts.
+        svn_users = []
+        eprops = project.extension_props[EXTENSION_NAME]
+        repo_id = eprops.repo_id
+        svn_url = urljoin(remote_url, repo_id)
+
+        if eprops.users:  # may be None
+            userdict = eprops.users.to_dict()
+            # Jump through some hoops to collect the user info from MongoDB in one query.
+            svninfo = {str2id(uid): userinfo for uid, userinfo in userdict.items()}
+            db_users = users_coll.find(
+                {'_id': {'$in': list(svninfo.keys())}},
+                projection={'full_name': 1, 'email': 1},
+            )
+            for db_user in db_users:
+                svninfo.setdefault(db_user['_id'], {})['db'] = db_user
+
+            svn_users = sorted(svninfo.values(), key=lambda item: item.get('username', ''))
         else:
             svn_url = ''
             repo_id = ''
 
-        return project_settings(project,
-                                svn_url=svn_url,
-                                repo_id=repo_id,
-                                remote_url=remote_url,
-                                **template_args)
+        return flask.render_template('svnman/project_settings/settings.html',
+                                     project=project,
+                                     svn_url=svn_url,
+                                     repo_id=repo_id,
+                                     remote_url=remote_url,
+                                     svn_users=svn_users,
+                                     **template_args)
 
     def is_svnman_project(self, project: pillarsdk.Project) -> bool:
         """Checks whether the project is correctly set up for SVNman."""
@@ -256,24 +280,69 @@ class SVNManExtension(PillarExtension):
         projects = pillarsdk.Project.all(params, api=api)
         return projects
 
-    def grant_access(self, project: pillarsdk.Project, repo_id: str, user_id: str):
-        """Grants access to the given user."""
+    def hash_password(self, passwd: str) -> str:
+        """Returns the BCrypt'ed password."""
+
+        import bcrypt
+
+        salt = bcrypt.gensalt()
+        hashed = bcrypt.hashpw(passwd.encode(), salt)
+        return hashed.decode()
+
+    def modify_access(self, project: pillarsdk.Project, repo_id: str, *,
+                      grant_user_id: str = '', grant_passwd: str = '',
+                      revoke_user_id: str = ''):
+        """Grants or revokes access to/from the given user."""
+
+        if bool(grant_user_id) == bool(revoke_user_id):
+            raise ValueError('pass either grant_user_id or revoke_user_id, not both/none')
+
+        if grant_user_id:
+            grant_revoke = 'grant'
+            grant_passwd = self.hash_password(grant_passwd) if grant_passwd else UNSET_PASSWORD
+        else:
+            grant_revoke = 'revoke'
 
         eprops, proj = self._get_prop_props(project)
         proj_repo_id = eprops.get('repo_id')
+        proj_oid = str2id(proj['_id'])
+
         if proj_repo_id != repo_id:
             self._log.warning('project %s is linked to repo %r, not to %r, '
-                              'refusing to grant access',
-                              proj['_id'], proj_repo_id, repo_id)
+                              'refusing to %s access',
+                              proj_oid, proj_repo_id, grant_revoke, repo_id)
             raise ValueError()
 
-        db_user = self._get_db_user(proj, repo_id, user_id)
-        username = db_user['username']
-        password = '$2y$10$password-not-yet-set'
-        self._log.info('granting user %s (%r) access to repo %s of project %s',
-                       user_id, username, repo_id, proj['_id'])
-
-        self.remote.modify_access(repo_id, grant=[(username, password)], revoke=[])
+        users = eprops.setdefault('users', {})
+        if grant_user_id:
+            db_user = self._get_db_user(proj, repo_id, grant_user_id)
+            username = db_user['username']
+            grant = [(username, grant_passwd)]
+            revoke = []
+            users[grant_user_id] = {'username': username,
+                                    'pw_set': grant_passwd != UNSET_PASSWORD}
+        else:
+            user_info = users.pop(revoke_user_id, None)
+            if not user_info:
+                self._log.warning('unable to revoke user %s access from repo %s of project %s:'
+                                  ' that user has no access', revoke_user_id, repo_id, proj_oid)
+                return
+            username = user_info['username']
+            grant = []
+            revoke = [username]
+
+        self._log.info('%sing user %s (%r) access to repo %s of project %s',
+                       grant_revoke.rstrip('e'), grant_user_id or revoke_user_id, username,
+                       repo_id, proj_oid)
+
+        self.remote.modify_access(repo_id, grant=grant, revoke=revoke)
+
+        proj_coll = current_app.db('projects')
+        res = proj_coll.update_one({'_id': proj_oid},
+                                   {'$set': {f'extension_props.{EXTENSION_NAME}.users': users}})
+        if res.matched_count != 1:
+            self._log.error('Matched count was %d, result: %s', res.matched_count, res.raw_result)
+            raise ValueError('Error updating MongoDB')
 
     def _get_db_user(self, proj, repo_id, user_id) -> dict:
         """Returns the user from the database.
index d2f3fc2..53b5d23 100644 (file)
@@ -115,10 +115,12 @@ class API:
                 return f'$2y${p[4:]}'
             return p
 
-        self._log.info('Modifying access rules for repository %r', repo_id)
         grants = [{'username': u,
                    'password': changehash(p)} for u, p in grant]
 
+        self._log.info('Modifying access rules for repository %r: grants=%s revokes=%s',
+                       repo_id, [u for u, p in grant], revoke)
+
         resp = self._request('POST', f'repo/{repo_id}/access', json={
             'grant': grants,
             'revoke': revoke,
index 93df405..6a56fee 100644 (file)
@@ -1,3 +1,4 @@
+import functools
 import logging
 
 from flask import Blueprint, render_template, jsonify, request
@@ -19,8 +20,6 @@ log = logging.getLogger(__name__)
 def require_project_put(projections: dict = None):
     """Endpoint decorator, translates project_url into an actual project and checks PUT access."""
 
-    import functools
-
     if callable(projections):
         raise TypeError('Use with @require_project_put() <-- note the parentheses')
 
@@ -41,6 +40,27 @@ def require_project_put(projections: dict = None):
     return decorator
 
 
+def wrap_svnman_exceptions(wrapped):
+    @functools.wraps(wrapped)
+    def decorator(*args, **kwargs):
+        from . import exceptions
+
+        try:
+            return wrapped(*args, **kwargs)
+        except (OSError, IOError):
+            log.exception('%s(%s, %s): unable to reach SVNman API', wrapped, args, kwargs)
+            resp = jsonify(_message='unable to reach SVNman API server')
+            resp.status_code = 500
+            return resp
+        except exceptions.RemoteError as ex:
+            log.error('%s(%s, %s): API sent us an error: %s', ex, wrapped, args, kwargs)
+            resp = jsonify(_message=str(ex))
+            resp.status_code = 500
+            return resp
+
+    return decorator
+
+
 @blueprint.route('/')
 def index():
     api = pillar_api()
@@ -56,6 +76,19 @@ def index():
                            projects=projects)
 
 
+def project_settings(project: pillarsdk.Project, **template_args: dict):
+    """Renders the project settings page for Subversion projects."""
+
+    # Based on the project state, we can render a different template.
+    if not current_svnman.is_svnman_project(project):
+        return render_template('svnman/project_settings/offer_create_repo.html',
+                               project=project, **template_args)
+
+    return render_template('svnman/project_settings/settings.html',
+                           project=project,
+                           **template_args)
+
+
 def error_service_not_available():
     if request.is_xhr:
         resp = jsonify({'_message': 'Subversion service not available to your account'})
@@ -68,85 +101,53 @@ def error_service_not_available():
 @blueprint.route('/<project_url>/create-repo', methods=['POST'])
 @require_login(require_cap='svn-use', error_view=error_service_not_available)
 @require_project_put()
+@wrap_svnman_exceptions
 def create_repo(project: pillarsdk.Project):
     log.info('going to create repository for project url=%r on behalf of user %s (%s)',
              project.url, current_user.user_id, current_user.email)
 
-    from . import exceptions
-
-    try:
-        current_svnman.create_repo(project, f'{current_user.full_name} <{current_user.email}>')
-    except (OSError, IOError):
-        log.exception('unable to reach SVNman API')
-        resp = jsonify(_message='unable to reach SVNman API server')
-        resp.status_code = 500
-        return resp
-    except exceptions.RemoteError as ex:
-        log.error('API sent us an error: %s', ex)
-        resp = jsonify(_message=str(ex))
-        resp.status_code = 500
-        return resp
+    current_svnman.create_repo(project, f'{current_user.full_name} <{current_user.email}>')
     return '', 204
 
 
 @blueprint.route('/<project_url>/delete-repo/<repo_id>', methods=['POST'])
 @require_login(require_cap='svn-use', error_view=error_service_not_available)
 @require_project_put()
+@wrap_svnman_exceptions
 def delete_repo(project: pillarsdk.Project, repo_id: str):
     log.info('going to delete repository %s for project url=%r on behalf of user %s (%s)',
              repo_id, project.url, current_user.user_id, current_user.email)
 
-    from . import exceptions
-
-    try:
-        current_svnman.delete_repo(project, repo_id)
-    except (OSError, IOError):
-        log.exception('unable to reach SVNman API')
-        resp = jsonify(_message='unable to reach SVNman API server')
-        resp.status_code = 500
-        return resp
-    except exceptions.RemoteError as ex:
-        log.error('API sent us an error: %s', ex)
-        resp = jsonify(_message=str(ex))
-        resp.status_code = 500
-        return resp
+    current_svnman.delete_repo(project, repo_id)
     return '', 204
 
 
-def project_settings(project: pillarsdk.Project, **template_args: dict):
-    """Renders the project settings page for Subversion projects."""
-
-    # Based on the project state, we can render a different template.
-    if not current_svnman.is_svnman_project(project):
-        return render_template('svnman/project_settings/offer_create_repo.html',
-                               project=project, **template_args)
-
-    return render_template('svnman/project_settings/settings.html',
-                           project=project,
-                           **template_args)
-
-
 @blueprint.route('/<project_url>/grant-access/<repo_id>', methods=['POST'])
 @require_login(require_cap='svn-use', error_view=error_service_not_available)
 @require_project_put()
+@wrap_svnman_exceptions
 def grant_access(project: pillarsdk.Project, repo_id: str):
     user_id = request.form['user_id']
+    password = request.form.get('password', '')
+
     log.info('going to grant access to user %s on repository %s for project url=%r '
              'on behalf of user %s (%s)',
              user_id, repo_id, project.url, current_user.user_id, current_user.email)
 
-    from . import exceptions
+    current_svnman.modify_access(project, repo_id, grant_user_id=user_id, grant_passwd=password)
+    return '', 204
 
-    try:
-        current_svnman.grant_access(project, repo_id, user_id)
-    except (OSError, IOError):
-        log.exception('unable to reach SVNman API')
-        resp = jsonify(_message='unable to reach SVNman API server')
-        resp.status_code = 500
-        return resp
-    except exceptions.RemoteError as ex:
-        log.error('API sent us an error: %s', ex)
-        resp = jsonify(_message=str(ex))
-        resp.status_code = 500
-        return resp
+
+@blueprint.route('/<project_url>/revoke-access/<repo_id>', methods=['POST'])
+@require_login(require_cap='svn-use', error_view=error_service_not_available)
+@require_project_put()
+@wrap_svnman_exceptions
+def revoke_access(project: pillarsdk.Project, repo_id: str):
+    user_id = request.form['user_id']
+
+    log.info('going to revoke access from user %s on repository %s for project url=%r '
+             'on behalf of user %s (%s)',
+             user_id, repo_id, project.url, current_user.user_id, current_user.email)
+
+    current_svnman.modify_access(project, repo_id, revoke_user_id=user_id)
     return '', 204