Repository: jupyterhub/oauthenticator

Issue:
[GitHub] We can authorize organizations, but what about teams within them?
Currently GitHub authentication is setup to whitelist individual users or entire organizations:
https://github.com/jupyterhub/oauthenticator/blob/master/oauthenticator/github.py

See here for the implementation:
https://zero-to-jupyterhub.readthedocs.io/en/latest/authentication.html?highlight=auth#github

It would be great to be able to whitelist Teams within a given organization:
https://developer.github.com/v3/teams/members/#get-team-membership 

We are currently creating many new organizations on github for week-long tutorials to grant time-limited access to hubs. Having team-based authentication could help with a few scenarios:

1) medium/large github organizations with application-specific hubs (https://github.com/pangeo-data)

2) could also be a really useful feature for resource access based on team membership within a hub (for example, mapping team name to group id)?



Comments:

Patch:
diff --git a/docs/source/github.md b/docs/source/github.md
index d5e2663..170b51e 100644
--- a/docs/source/github.md
+++ b/docs/source/github.md
@@ -39,3 +39,33 @@ To use this expanded user information, you will need to subclass your
 current spawner and modify the subclass to read these fields from
 `auth_state` and then use this information to provision your Notebook or
 Lab user.
+
+## Restricting access
+
+### Organizations
+
+If you would like to restrict access to members of specific GitHub organizations
+you can pass a list of organization names to `allowed_organizations`.
+
+For example, the below will ensure that only members of `org_a` or
+`org_b` will be authorized to access.
+
+`c.GitHubOAuthenticator.allowed_organizations = ["org_a", "org_b"]`
+
+### Teams
+
+It is also possible to restrict access to members of specific teams within
+organizations using the syntax: `<organization>:<team-name>`.
+
+For example, the below will only allow members of `org_a`, or
+`team_1` in `org_b` access. Members of `org_b` but not `team_1` will be
+unauthorized to access.
+
+`c.GitHubOAuthenticator.allowed_organizations = ["org_a", "org_b:team_1"]`
+
+### Notes
+
+- Restricting access by either organization or team requires the `read:org`
+  scope
+- Ensure you use the organization/team name as it appears in the GitHub url
+  - E.g. Use `jupyter` instead of `Project Jupyter`
diff --git a/oauthenticator/github.py b/oauthenticator/github.py
index e29777d..9526b1f 100644
--- a/oauthenticator/github.py
+++ b/oauthenticator/github.py
@@ -225,13 +225,13 @@ class GitHubOAuthenticator(OAuthenticator):
         headers = _api_headers(access_token)
         # Check membership of user `username` for organization `org` via api [check-membership](https://developer.github.com/v3/orgs/members/#check-membership)
         # With empty scope (even if authenticated by an org member), this
-        #  will only await public org members.  You want 'read:org' in order
-        #  to be able to iterate through all members.
-        check_membership_url = "%s/orgs/%s/members/%s" % (
-            self.github_api,
-            org,
-            username,
-        )
+        # will only await public org members.  You want 'read:org' in order
+        # to be able to iterate through all members. If you would only like to
+        # allow certain teams within an organisation, specify
+        # allowed_organisations = {org_name:team_name}
+
+        check_membership_url = self._build_check_membership_url(org, username)
+
         req = HTTPRequest(
             check_membership_url,
             method="GET",
@@ -260,6 +260,13 @@ class GitHubOAuthenticator(OAuthenticator):
             )
         return False
 
+    def _build_check_membership_url(self, org: str, username: str) -> str:
+        if ":" in org:
+            org, team = org.split(":")
+            return f"{self.github_api}/orgs/{org}/teams/{team}/members/{username}"
+        else:
+            return f"{self.github_api}/orgs/{org}/members/{username}"
+
 
 class LocalGitHubOAuthenticator(LocalAuthenticator, GitHubOAuthenticator):
 


Test patch:
diff --git a/oauthenticator/tests/test_github.py b/oauthenticator/tests/test_github.py
index 0211cd4..16c04c9 100644
--- a/oauthenticator/tests/test_github.py
+++ b/oauthenticator/tests/test_github.py
@@ -7,6 +7,7 @@ from urllib.parse import parse_qs
 from urllib.parse import urlparse
 
 from pytest import fixture
+from pytest import mark
 from tornado.httpclient import HTTPResponse
 from tornado.httputil import HTTPHeaders
 from traitlets.config import Config
@@ -71,40 +72,42 @@ async def test_allowed_org_membership(github_client):
 
     ## Mock Github API
 
-    teams = {
+    orgs = {
         'red': ['grif', 'simmons', 'donut', 'sarge', 'lopez'],
         'blue': ['tucker', 'caboose', 'burns', 'sheila', 'texas'],
     }
 
+    org_teams = {'blue': {'alpha': ['tucker', 'caboose', 'burns']}}
+
     member_regex = re.compile(r'/orgs/(.*)/members')
 
-    def team_members(paginate, request):
+    def org_members(paginate, request):
         urlinfo = urlparse(request.url)
-        team = member_regex.match(urlinfo.path).group(1)
+        org = member_regex.match(urlinfo.path).group(1)
 
-        if team not in teams:
+        if org not in orgs:
             return HTTPResponse(request, 404)
 
         if not paginate:
-            return [user_model(m) for m in teams[team]]
+            return [user_model(m) for m in orgs[org]]
         else:
             page = parse_qs(urlinfo.query).get('page', ['1'])
             page = int(page[0])
-            return team_members_paginated(
-                team, page, urlinfo, functools.partial(HTTPResponse, request)
+            return org_members_paginated(
+                org, page, urlinfo, functools.partial(HTTPResponse, request)
             )
 
-    def team_members_paginated(team, page, urlinfo, response):
-        if page < len(teams[team]):
+    def org_members_paginated(org, page, urlinfo, response):
+        if page < len(orgs[org]):
             headers = make_link_header(urlinfo, page + 1)
-        elif page == len(teams[team]):
+        elif page == len(orgs[org]):
             headers = {}
         else:
             return response(400)
 
         headers.update({'Content-Type': 'application/json'})
 
-        ret = [user_model(teams[team][page - 1])]
+        ret = [user_model(orgs[org][page - 1])]
 
         return response(
             200,
@@ -112,19 +115,42 @@ async def test_allowed_org_membership(github_client):
             buffer=BytesIO(json.dumps(ret).encode('utf-8')),
         )
 
-    membership_regex = re.compile(r'/orgs/(.*)/members/(.*)')
+    org_membership_regex = re.compile(r'/orgs/(.*)/members/(.*)')
 
-    def team_membership(request):
+    def org_membership(request):
         urlinfo = urlparse(request.url)
-        urlmatch = membership_regex.match(urlinfo.path)
-        team = urlmatch.group(1)
+        urlmatch = org_membership_regex.match(urlinfo.path)
+        org = urlmatch.group(1)
         username = urlmatch.group(2)
-        print('Request team = %s, username = %s' % (team, username))
-        if team not in teams:
-            print('Team not found: team = %s' % (team))
+        print('Request org = %s, username = %s' % (org, username))
+        if org not in orgs:
+            print('Org not found: org = %s' % (org))
+            return HTTPResponse(request, 404)
+        if username not in orgs[org]:
+            print('Member not found: org = %s, username = %s' % (org, username))
             return HTTPResponse(request, 404)
-        if username not in teams[team]:
-            print('Member not found: team = %s, username = %s' % (team, username))
+        return HTTPResponse(request, 204)
+
+    team_membership_regex = re.compile(r'/orgs/(.*)/teams/(.*)/members/(.*)')
+
+    def team_membership(request):
+        urlinfo = urlparse(request.url)
+        urlmatch = team_membership_regex.match(urlinfo.path)
+        org = urlmatch.group(1)
+        team = urlmatch.group(2)
+        username = urlmatch.group(3)
+        print('Request org = %s, team = %s username = %s' % (org, team, username))
+        if org not in orgs:
+            print('Org not found: org = %s' % (org))
+            return HTTPResponse(request, 404)
+        if team not in org_teams[org]:
+            print('Team not found in org: team = %s, org = %s' % (team, org))
+            return HTTPResponse(request, 404)
+        if username not in org_teams[org][team]:
+            print(
+                'Member not found: org = %s, team = %s, username = %s'
+                % (org, team, username)
+            )
             return HTTPResponse(request, 404)
         return HTTPResponse(request, 204)
 
@@ -132,8 +158,9 @@ async def test_allowed_org_membership(github_client):
 
     for paginate in (False, True):
         client_hosts = client.hosts['api.github.com']
-        client_hosts.append((membership_regex, team_membership))
-        client_hosts.append((member_regex, functools.partial(team_members, paginate)))
+        client_hosts.append((team_membership_regex, team_membership))
+        client_hosts.append((org_membership_regex, org_membership))
+        client_hosts.append((member_regex, functools.partial(org_members, paginate)))
 
         authenticator.allowed_organizations = ['blue']
 
@@ -156,10 +183,42 @@ async def test_allowed_org_membership(github_client):
         user = await authenticator.authenticate(handler)
         assert user['name'] == 'donut'
 
+        # test team membership
+        authenticator.allowed_organizations = ['blue:alpha', 'red']
+
+        handler = client.handler_for_user(user_model('tucker'))
+        user = await authenticator.authenticate(handler)
+        assert user['name'] == 'tucker'
+
+        handler = client.handler_for_user(user_model('grif'))
+        user = await authenticator.authenticate(handler)
+        assert user['name'] == 'grif'
+
+        handler = client.handler_for_user(user_model('texas'))
+        user = await authenticator.authenticate(handler)
+        assert user is None
+
         client_hosts.pop()
         client_hosts.pop()
 
 
+@mark.parametrize(
+    "org, username, expected",
+    [
+        ("blue", "texas", "https://api.github.com/orgs/blue/members/texas"),
+        (
+            "blue:alpha",
+            "tucker",
+            "https://api.github.com/orgs/blue/teams/alpha/members/tucker",
+        ),
+        ("red", "grif", "https://api.github.com/orgs/red/members/grif"),
+    ],
+)
+def test_build_check_membership_url(org, username, expected):
+    output = GitHubOAuthenticator()._build_check_membership_url(org, username)
+    assert output == expected
+
+
 def test_deprecated_config(caplog):
     cfg = Config()
     cfg.GitHubOAuthenticator.github_organization_whitelist = ["jupy"]