Repository: liberapay/liberapay.com

Issue:
Connecting account to GitLab requests way too many permissions
If I want to connect my liberapay account to my GitLab account, I have to give liberapay permissions to do arbitrary things as me on GitLab, which I am not comfortable with for what should be obvious reasons.


Discussion:
I felt the same. Is the case that the permissions in the gitlab API are not granular enough? From what I could find in the documentation, these are the permission levels: https://gitlab.com/help/user/profile/personal_access_tokens.md#limiting-scopes-of-a-personal-access-token (also applies to OAuth, I think, but not documented there: https://gitlab.com/help/api/oauth2.md)

But perhaps it would be sufficient for liberapay to get access with a `read_user` scope and then would `GET /projects` return only public projects, which should be all that is needed? https://gitlab.com/help/api/projects.md#list-all-projects

I don't really have enough insight into how this works and haven't tested anything, but I noticed that the gitlab API v3 ([which liberapay uses?]( https://github.com/liberapay/liberapay.com/blob/master/liberapay/elsewhere/gitlab.py#L27)) should be deprecated (and they're moving to graphQl), so maybe this has to be looked into soon also because of that:


I've just tried the `read_user` scope, it doesn't allow us to get the list of the user's public repositories.

```
DEBUG:urllib3.connectionpool:https://gitlab.com:443 "GET /api/v4/projects?owned=true&visibility=public&order_by=last_activity_at&per_page=100 HTTP/1.1" 403 140
ERROR:liberapay.elsewhere:gitlab.com responded with 403:
{"error":"insufficient_scope","error_description":"The request requires higher privileges than provided by the access token.","scope":"api"}
```
That seems odd to me - if the repositories are public, Liberapay shouldn't even need a permission. It can't be a security restriction since regular scraping would work just as well.

I wonder if this is a bug on Gitlab's side of things?

[Documentation](https://gitlab.com/help/api/projects.md#list-user-projects) says that when accessed without authentication, only public projects should be returned. But public projects would be enough, right?
I've also tried the `/users/:user_id/projects` endpoint, it returns the same error.
Maybe I'm missing something but I can't think of what they're even protecting; all the information is publicly accessible.

I can do a little bit of experimentation once I get some free time today or tomorrow. If I can confirm, want me to head over and file a bug on their end?
GitLab's API has never handled permissions well, we've been waiting for more than 2 years for <https://gitlab.com/gitlab-org/gitlab-ce/issues/13795>.
Oof. That's a little bit disappointing.

Resolving patch:
diff --git a/liberapay/elsewhere/_base.py b/liberapay/elsewhere/_base.py
--- a/liberapay/elsewhere/_base.py
+++ b/liberapay/elsewhere/_base.py
@@ -22,6 +22,19 @@
 logger = logging.getLogger('liberapay.elsewhere')
 
 
+class APIEndpoint(str):
+
+    use_session = True
+
+    def __new__(cls, path, **attrs):
+        self = str.__new__(cls, path)
+        self.__dict__.update(attrs)
+        return self
+
+    def __init__(self, path, **attrs):
+        pass
+
+
 class UserInfo(object):
     """A simple container for a user's info.
 
@@ -64,6 +77,8 @@ class Platform(object):
     x_is_team = not_available
     x_description = not_available
 
+    x_repo_owner_id = not_available
+
     required_attrs = ('account_url', 'display_name', 'name')
 
     def __init__(self, api_key, api_secret, callback_url, api_url=None, auth_url=None,
@@ -357,10 +372,12 @@ def get_repos(self, account, page_url=None, sess=None, refresh=True):
                 user_id=urlquote(account.user_id),
                 user_name=urlquote(account.user_name or ''),
             )
+        if not getattr(self.api_repos_path, 'use_session', True):
+            sess = None
         r = self.api_get(account.domain, page_url, sess=sess)
         repos, count, pages_urls = self.api_paginator(r, self.api_parser(r))
         repos = [self.extract_repo_info(repo, account.domain) for repo in repos]
-        if repos and repos[0].owner_id != account.user_id:
+        if '{user_name}' in self.api_repos_path and repos and repos[0].owner_id != account.user_id:
             # https://hackerone.com/reports/452920
             if not refresh:
                 raise TokenExpiredError()
@@ -382,6 +399,8 @@ def get_starred_repos(self, account, sess, page_url=None):
                 user_name=urlquote(account.user_name or ''),
             )
         assert page_url[:1] == '/'
+        if not getattr(self.api_starred_path, 'use_session', True):
+            sess = None
         r = self.api_get(account.domain, page_url, sess=sess)
         repos, count, pages_urls = self.api_paginator(r, self.api_parser(r))
         repos = [self.extract_repo_info(repo, account.domain) for repo in repos]
diff --git a/liberapay/elsewhere/gitlab.py b/liberapay/elsewhere/gitlab.py
--- a/liberapay/elsewhere/gitlab.py
+++ b/liberapay/elsewhere/gitlab.py
@@ -1,4 +1,4 @@
-from liberapay.elsewhere._base import PlatformOAuth2
+from liberapay.elsewhere._base import APIEndpoint, PlatformOAuth2
 from liberapay.elsewhere._extractors import key
 from liberapay.elsewhere._paginators import header_links_paginator
 
@@ -17,6 +17,7 @@ class GitLab(PlatformOAuth2):
     # GitLab uses https://github.com/doorkeeper-gem/doorkeeper
     auth_url = 'https://gitlab.com/oauth/authorize'
     access_token_url = 'https://gitlab.com/oauth/token'
+    oauth_default_scope = ['read_user']
 
     # can_auth_with_client_credentials = True
     # https://gitlab.com/gitlab-org/gitlab-ce/issues/13795
@@ -30,8 +31,14 @@ class GitLab(PlatformOAuth2):
     api_user_name_info_path = '/users?username={user_name}'
     api_user_self_info_path = '/user'
     api_team_members_path = '/groups/{user_name}/members'
-    api_repos_path = '/projects?owned=true&visibility=public&order_by=last_activity_at&per_page=100'
-    api_starred_path = '/projects?starred=true&visibility=public'
+    api_repos_path = APIEndpoint(
+        '/users/{user_id}/projects?owned=true&visibility=public&order_by=last_activity_at&per_page=100',
+        use_session=False
+    )
+    api_starred_path = APIEndpoint(
+        '/users/{user_id}/projects?starred=true&visibility=public',
+        use_session=False
+    )
 
     # User info extractors
     x_user_id = key('id')
@@ -49,4 +56,3 @@ class GitLab(PlatformOAuth2):
     x_repo_last_update = key('last_activity_at')
     x_repo_is_fork = key('forked_from_project', clean=bool)
     x_repo_stars_count = key('star_count')
-    x_repo_owner_id = key('owner', clean=lambda d: d['id'])


Test patch: