Repository: jupyterhub/jupyterhub

Issue:
[rbac] Group roles in user model role list
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Bug description
<!-- Use this section to clearly and concisely describe the bug. -->
Occurs when a user is a member of a group which has a role. The group role is added to the user's role list (and multiplies) every time the user makes an API request, i.e., after 3 requests the user's model will show the group role in the user's role list 3 times. 

#### Expected behaviour
<!-- Tell us what you thought would happen. -->
The user's own roles should not include any group roles. These are only used to extend the user's privileges during API requests but the users themselves are not bearers of their group roles.  
 
#### Actual behaviour
<!-- Tell us what actually happens. -->
As mentioned above, a user's group role is added to the user's roles every time they make a request.  
### How to reproduce
<!-- Use this section to describe the steps that a user would take to experience this bug. -->
1. Create a user (either with or without specific roles)
2. Create a group and assign it some role
3. Add the user to the group
4. Issue an API token for the user
5. Make few API requests using the user's token
6. Check the user's model which will show the group's role in the user's role list as many times as performed API requests 

### Your personal set up
<!--
Tell us a little about the system you're using.
Please include information about how you installed,
e.g. are you using a distribution such as zero-to-jupyterhub or the-littlest-jupyterhub.
 -->
Development install
 - OS:
 <!-- [e.g. ubuntu 20.04, macOS 11.0] -->
 ubuntu 20.04
 - Version(s):
 <!-- e.g. jupyterhub --version, python --version --->
jupyterhub 2.0.0.dev, python 3.8.5
- <details><summary>Full environment</summary>
<!-- For reproduction, it's useful to have the full environment. For example, the output of `pip freeze` or `conda list` --->

```
# Name                    Version                   Build  Channel
_libgcc_mutex             0.1                        main  
alabaster                 0.7.12                   pypi_0    pypi
alabaster-jupyterhub      0.1.8                    pypi_0    pypi
alembic                   1.4.3                    pypi_0    pypi
appdirs                   1.4.4                    pypi_0    pypi
argon2-cffi               20.1.0                   pypi_0    pypi
async-generator           1.10                     pypi_0    pypi
attrs                     20.3.0                   pypi_0    pypi
autodoc-traits            0.1.0.dev0               pypi_0    pypi
babel                     2.9.0                    pypi_0    pypi
backcall                  0.2.0                    pypi_0    pypi
beautifulsoup4            4.9.3                    pypi_0    pypi
bleach                    3.2.1                    pypi_0    pypi
brotlipy                  0.7.0           py38h27cfd23_1003  
ca-certificates           2021.4.13            h06a4308_1  
certifi                   2020.12.5        py38h06a4308_0  
certipy                   0.1.3                    pypi_0    pypi
cffi                      1.14.5           py38h261ae71_0  
cfgv                      3.2.0                    pypi_0    pypi
chardet                   4.0.0           py38h06a4308_1003  
codecov                   2.1.10                   pypi_0    pypi
commonmark                0.9.1                    pypi_0    pypi
conda                     4.10.0           py38h06a4308_0  
conda-package-handling    1.7.3            py38h27cfd23_1  
coverage                  5.3                      pypi_0    pypi
cryptography              3.4.7            py38hd23ed53_0  
dataproperty              0.50.0                   pypi_0    pypi
decorator                 4.4.2                    pypi_0    pypi
defusedxml                0.6.0                    pypi_0    pypi
distlib                   0.3.1                    pypi_0    pypi
docutils                  0.16                     pypi_0    pypi
entrypoints               0.3                      pypi_0    pypi
filelock                  3.0.12                   pypi_0    pypi
html5lib                  1.1                      pypi_0    pypi
identify                  1.5.10                   pypi_0    pypi
idna                      2.10               pyhd3eb1b0_0  
imagesize                 1.2.0                    pypi_0    pypi
iniconfig                 1.1.1                    pypi_0    pypi
ipykernel                 5.4.2                    pypi_0    pypi
ipython                   7.19.0                   pypi_0    pypi
ipython-genutils          0.2.0                    pypi_0    pypi
jedi                      0.17.2                   pypi_0    pypi
jinja2                    2.11.2                   pypi_0    pypi
jsonpointer               2.0                      pypi_0    pypi
jsonschema                3.2.0                    pypi_0    pypi
jupyter-client            6.1.7                    pypi_0    pypi
jupyter-core              4.7.0                    pypi_0    pypi
jupyter-telemetry         0.1.0                    pypi_0    pypi
jupyterhub                1.4.0.dev0                dev_0    <develop>
jupyterlab-pygments       0.1.2                    pypi_0    pypi
ld_impl_linux-64          2.33.1               h53a641e_7  
libffi                    3.3                  he6710b0_2  
libgcc-ng                 9.1.0                hdf63c60_0  
libstdcxx-ng              9.1.0                hdf63c60_0  
mako                      1.1.3                    pypi_0    pypi
markdown-it-py            0.6.2                    pypi_0    pypi
markupsafe                1.1.1                    pypi_0    pypi
mbstrdecoder              1.0.1                    pypi_0    pypi
mdit-py-plugins           0.2.6                    pypi_0    pypi
mistune                   0.8.4                    pypi_0    pypi
mock                      4.0.3                    pypi_0    pypi
msgfy                     0.1.0                    pypi_0    pypi
myst-parser               0.13.5                   pypi_0    pypi
nbclient                  0.5.1                    pypi_0    pypi
nbconvert                 6.0.7                    pypi_0    pypi
nbformat                  5.0.8                    pypi_0    pypi
ncurses                   6.2                  he6710b0_1  
nest-asyncio              1.4.3                    pypi_0    pypi
nodeenv                   1.5.0                    pypi_0    pypi
nodejs                    10.13.0              he6710b0_0  
notebook                  6.1.5                    pypi_0    pypi
oauthlib                  3.1.0                    pypi_0    pypi
openssl                   1.1.1k               h27cfd23_0  
packaging                 20.8                     pypi_0    pypi
pamela                    1.0.0                    pypi_0    pypi
pandocfilters             1.4.3                    pypi_0    pypi
parso                     0.7.1                    pypi_0    pypi
pathvalidate              2.3.2                    pypi_0    pypi
pexpect                   4.8.0                    pypi_0    pypi
pickleshare               0.7.5                    pypi_0    pypi
pip                       21.0.1           py38h06a4308_0  
pluggy                    0.13.1                   pypi_0    pypi
pre-commit                2.9.3                    pypi_0    pypi
prometheus-client         0.9.0                    pypi_0    pypi
prompt-toolkit            3.0.8                    pypi_0    pypi
ptyprocess                0.6.0                    pypi_0    pypi
py                        1.10.0                   pypi_0    pypi
pycosat                   0.6.3            py38h7b6447c_1  
pycparser                 2.20                       py_2  
pydata-sphinx-theme       0.4.1                    pypi_0    pypi
pygments                  2.7.3                    pypi_0    pypi
pyopenssl                 20.0.1             pyhd3eb1b0_1  
pyparsing                 2.4.7                    pypi_0    pypi
pyrsistent                0.17.3                   pypi_0    pypi
pysocks                   1.7.1            py38h06a4308_0  
pytablewriter             0.58.0                   pypi_0    pypi
pytest                    6.2.0                    pypi_0    pypi
pytest-asyncio            0.14.0                   pypi_0    pypi
pytest-cov                2.10.1                   pypi_0    pypi
python                    3.8.5                h7579374_1  
python-dateutil           2.8.1                    pypi_0    pypi
python-editor             1.0.4                    pypi_0    pypi
python-json-logger        2.0.1                    pypi_0    pypi
pytz                      2020.5                   pypi_0    pypi
pyyaml                    5.3.1                    pypi_0    pypi
pyzmq                     20.0.0                   pypi_0    pypi
readline                  8.1                  h27cfd23_0  
recommonmark              0.7.1                    pypi_0    pypi
requests                  2.25.1             pyhd3eb1b0_0  
requests-mock             1.8.0                    pypi_0    pypi
ruamel.yaml               0.16.12          py38h7b6447c_1  
ruamel.yaml.clib          0.2.2            py38h7b6447c_0  
ruamel_yaml               0.15.100         py38h27cfd23_0  
send2trash                1.5.0                    pypi_0    pypi
setuptools                52.0.0           py38h06a4308_0  
six                       1.15.0           py38h06a4308_0  
snowballstemmer           2.0.0                    pypi_0    pypi
soupsieve                 2.1                      pypi_0    pypi
sphinx                    3.4.3                    pypi_0    pypi
sphinx-copybutton         0.3.1                    pypi_0    pypi
sphinx-jsonschema         1.16.7                   pypi_0    pypi
sphinxcontrib-applehelp   1.0.2                    pypi_0    pypi
sphinxcontrib-devhelp     1.0.2                    pypi_0    pypi
sphinxcontrib-htmlhelp    1.0.3                    pypi_0    pypi
sphinxcontrib-jsmath      1.0.1                    pypi_0    pypi
sphinxcontrib-qthelp      1.0.3                    pypi_0    pypi
sphinxcontrib-serializinghtml 1.1.4                    pypi_0    pypi
sqlalchemy                1.3.20                   pypi_0    pypi
sqlite                    3.35.4               hdfb4753_0  
tabledata                 1.1.3                    pypi_0    pypi
tcolorpy                  0.0.8                    pypi_0    pypi
terminado                 0.9.1                    pypi_0    pypi
testpath                  0.4.4                    pypi_0    pypi
tk                        8.6.10               hbc83047_0  
toml                      0.10.2                   pypi_0    pypi
tornado                   6.1                      pypi_0    pypi
tqdm                      4.59.0             pyhd3eb1b0_1  
traitlets                 5.0.5                    pypi_0    pypi
typepy                    1.1.2                    pypi_0    pypi
urllib3                   1.26.4             pyhd3eb1b0_0  
virtualenv                20.2.2                   pypi_0    pypi
wcwidth                   0.2.5                    pypi_0    pypi
webencodings              0.5.1                    pypi_0    pypi
wheel                     0.36.2             pyhd3eb1b0_0  
xz                        5.2.5                h7b6447c_0  
yaml                      0.2.5                h7b6447c_0  
zlib                      1.2.11               h7b6447c_3
```
</details>

- <details><summary>Configuration</summary>
<!--
For JupyterHub, especially include information such as what Spawner and Authenticator are being used.
Be careful not to share any sensitive information.
You can paste jupyterhub_config.py below.
To exclude lots of comments and empty lines from auto-generated jupyterhub_config.py, you can do:
    grep -v '\(^#\|^[[:space:]]*$\)' jupyterhub_config.py
-->
Testing:
```python
async def test_user_model_roles(app, create_temp_role):
    user = add_user(app.db, app, name='jack')
    another_user = add_user(app.db, app, name='jill')
    
    group = orm.Group.find(app.db, name='A')
    if not group:
        group = orm.Group(name='A')
        app.db.add(group)
        app.db.commit()

    if user not in group.users:
        group.users.append(user)
        app.db.commit()

    if another_user not in group.users:
        group.users.append(another_user)
        app.db.commit()

    group_role = orm.Role.find(app.db, 'student-a')
    if not group_role: 
        create_temp_role(['read:groups!group=A'], 'student-a')
        roles.grant_role(app.db, group, rolename='student-a')
        group_role = orm.Role.find(app.db, 'student-a')
    
    # jack's API token
    token = user.new_api_token()
    
    headers = {'Authorization': 'token %s' % token}
    r = await api_request(app, 'users', method='get', headers=headers)
    assert r.status_code == 200
    r.raise_for_status()
    reply = r.json()

    print(reply)
    
    # right now this fails as it has the group role once in jack's roles (first use of the API token)
    #assert len(reply) == 1
    #assert reply[0]['name'] == 'jack'
    #assert group_role not in reply[0]['roles']

    headers = {'Authorization': 'token %s' % token}
    r = await api_request(app, 'groups', method='get', headers=headers)
    assert r.status_code == 200
    r.raise_for_status()
    reply = r.json()

    print(reply)
    

    headers = {'Authorization': 'token %s' % token}
    r = await api_request(app, 'users', method='get', headers=headers)
    assert r.status_code == 200
    r.raise_for_status()
    reply = r.json()

    print(reply)
    
    # this also fails as it has the group role 3 times (!) in jack's roles (3rd use of the API token)
    #assert len(reply) == 1
    #assert reply[0]['name'] == 'jack'
    # make it fail to get print statements
    assert group_role not in reply[0]['roles']
```

</details>

- <details><summary>Logs</summary>
<!--
Errors are often logged by jupytehub. How you get logs depends on your deployment.
With kubernetes it might be:

    kubectl get pod # hub pod name starts with hub...
    kubectl logs hub-...
    # or for a single-user server
    kubectl logs jupyter-username

Or the-littlest-jupyterhub:

    journalctl -u jupyterhub
    # or for a single-user server
    journalctl -u jupyter-username
-->
```python
[D 09:22.476 MockHub roles:450] Assigning default roles to User
[I 09:22.482 MockHub roles:353] Adding role user for User: jack
[D 09:22.497 MockHub roles:450] Assigning default roles to User
[I 09:22.501 MockHub roles:353] Adding role user for User: jill
[I 09:22.525 MockHub roles:353] Adding role student-a for Group: A
[D 09:22.530 MockHub roles:442] Assigning default roles to tokens
[I 09:22.532 MockHub roles:445] Added role token to token <APIToken('e7e2...', user='jack', client_id='jupyterhub')>
[D 09:22.551 MockHub base:266] Recording first activity for <APIToken('e7e2...', user='jack', client_id='jupyterhub')>
[D 09:22.551 MockHub base:266] Recording first activity for <User(jack 0/1 running)>
[D 09:22.556 MockHub base:311] Refreshing auth for jack
[D 09:22.556 MockHub base:421] Loading and parsing scopes
[W 09:22.557 MockHub scopes:89] Authenticated with token <APIToken('e7e2...', user='jack', client_id='jupyterhub')>
[W 09:22.559 MockHub scopes:56] [!user=, !group=] or [!user=, !server=] combinations of filters present, intersection between not considered. May result in lower than intended permissions.
[D 09:22.559 MockHub base:429] Found scopes [read:users:tokens!user=jack,read:groups!group=A,users:tokens!user=jack,users:servers!user=jack,users:name!user=jack,read:users:servers!user=jack,read:users:name!user=jack,users:activity!user=jack,users:groups!user=jack,read:users:activity!user=jack,users!user=jack,read:users!user=jack,read:users:groups!user=jack]
[D 09:22.559 MockHub scopes:232] Checking access via scope read:users
[D 09:22.559 MockHub scopes:154] Client has restricted access to /@/space%20word/hub/api/users via read:users. Internal filtering may apply
[D 09:22.566 MockHub base:254] Asking for user model of admin with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[D 09:22.571 MockHub base:254] Asking for user model of user with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[D 09:22.571 MockHub base:254] Asking for user model of jack with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[D 09:22.571 MockHub base:254] Asking for user model of jill with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[I 09:22.574 MockHub log:189] 200 GET /@/space%20word/hub/api/users (jack@127.0.0.1) 25.29ms
[D 09:22.586 MockHub base:421] Loading and parsing scopes
[W 09:22.587 MockHub scopes:89] Authenticated with token <APIToken('e7e2...', user='jack', client_id='jupyterhub')>
[W 09:22.587 MockHub scopes:56] [!user=, !group=] or [!user=, !server=] combinations of filters present, intersection between not considered. May result in lower than intended permissions.
[D 09:22.587 MockHub base:429] Found scopes [read:users:tokens!user=jack,read:groups!group=A,users:tokens!user=jack,users:servers!user=jack,users:name!user=jack,read:users:servers!user=jack,read:users:name!user=jack,users:activity!user=jack,users:groups!user=jack,read:users:activity!user=jack,users!user=jack,read:users!user=jack,read:users:groups!user=jack]
[D 09:22.587 MockHub scopes:232] Checking access via scope read:groups
[D 09:22.587 MockHub scopes:154] Client has restricted access to /@/space%20word/hub/api/groups via read:groups. Internal filtering may apply
[I 09:22.590 MockHub log:189] 200 GET /@/space%20word/hub/api/groups (jack@127.0.0.1) 6.41ms
[D 09:22.604 MockHub base:421] Loading and parsing scopes
[W 09:22.605 MockHub scopes:89] Authenticated with token <APIToken('e7e2...', user='jack', client_id='jupyterhub')>
[W 09:22.605 MockHub scopes:56] [!user=, !group=] or [!user=, !server=] combinations of filters present, intersection between not considered. May result in lower than intended permissions.
[D 09:22.605 MockHub base:429] Found scopes [read:users:tokens!user=jack,read:groups!group=A,users:tokens!user=jack,users:servers!user=jack,users:name!user=jack,read:users:servers!user=jack,read:users:name!user=jack,users:activity!user=jack,users:groups!user=jack,read:users:activity!user=jack,users!user=jack,read:users!user=jack,read:users:groups!user=jack]
[D 09:22.605 MockHub scopes:232] Checking access via scope read:users
[D 09:22.605 MockHub scopes:154] Client has restricted access to /@/space%20word/hub/api/users via read:users. Internal filtering may apply
[D 09:22.607 MockHub base:254] Asking for user model of admin with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[D 09:22.607 MockHub base:254] Asking for user model of user with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[D 09:22.607 MockHub base:254] Asking for user model of jack with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[D 09:22.607 MockHub base:254] Asking for user model of jill with scopes [read:users:tokens!user=jack, read:groups!group=A, users:tokens!user=jack, users:servers!user=jack, users:name!user=jack, read:users:servers!user=jack, read:users:name!user=jack, users:activity!user=jack, users:groups!user=jack, read:users:activity!user=jack, users!user=jack, read:users!user=jack, read:users:groups!user=jack]
[I 09:22.608 MockHub log:189] 200 GET /@/space%20word/hub/api/users (jack@127.0.0.1) 6.87ms
```
</details>



Discussion:
Thank you for opening your first issue in this project! Engagement like this is essential for open source projects! :hugs:
<br>If you haven't done so already, check out [Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/master/conduct/code_of_conduct.md). Also, please try to follow the issue template as it helps other other community members to contribute more effectively.
![welcome](https://raw.githubusercontent.com/jupyterhub/.github/master/images/welcome.jpg)
You can meet the other [Jovyans](https://jupyter.readthedocs.io/en/latest/community/content-community.html?highlight=jovyan#what-is-a-jovyan) by joining our [Discourse forum](http://discourse.jupyter.org/). There is also an intro thread there where you can stop by and say Hi! :wave:
<br>Welcome to the Jupyter community! :tada:

When I run this test with the current rbac branch (gafe43f32), this test passes, even with the commented-out  asserts back in. Maybe something's not quite right in the test? What's the exact commit you are testing?
Strange issue: when I run the test and the token-via-api, the test fails with a very weird database lock error, which indicates an unhandled error. I cannot track down what could be the cause at the moment.
db issue appears to be fixed by #3475 . Not sure if that's related to your underlying issue here, since I can't seem to reproduce the failure yet.
@minrk Thank you for trying it out, apologies for posting incomplete test. The corrections are:

1. `assert len(reply) == 1` is missing the relation to roles list should be `assert len(reply[0]['roles']) == 1`
2. `assert group_role not in reply[0]['roles']` is missing the `name` attr of the `group_role` and should be `assert group_role.name not in reply[0]['roles']`

The correct test is:

```python
async def test_user_model_roles(app, create_temp_role):
    user = add_user(app.db, app, name='jack')
    another_user = add_user(app.db, app, name='jill')
    
    group = orm.Group.find(app.db, name='A')
    if not group:
        group = orm.Group(name='A')
        app.db.add(group)
        app.db.commit()

    if user not in group.users:
        group.users.append(user)
        app.db.commit()

    if another_user not in group.users:
        group.users.append(another_user)
        app.db.commit()

    group_role = orm.Role.find(app.db, 'student-a')
    if not group_role: 
        create_temp_role(['read:groups!group=A'], 'student-a')
        roles.grant_role(app.db, group, rolename='student-a')
        group_role = orm.Role.find(app.db, 'student-a')
    
    # jack's API token
    token = user.new_api_token()
    
    headers = {'Authorization': 'token %s' % token}
    r = await api_request(app, 'users', method='get', headers=headers)
    assert r.status_code == 200
    r.raise_for_status()
    reply = r.json()

    print(reply)
    
    # right now this fails as it has the group role once in jack's roles (first use of the API token)
    #assert len(reply[0]['roles']) == 1
    #assert reply[0]['name'] == 'jack'
    #assert group_role.name not in reply[0]['roles']

    headers = {'Authorization': 'token %s' % token}
    r = await api_request(app, 'groups', method='get', headers=headers)
    assert r.status_code == 200
    r.raise_for_status()
    reply = r.json()

    print(reply)
    

    headers = {'Authorization': 'token %s' % token}
    r = await api_request(app, 'users', method='get', headers=headers)
    assert r.status_code == 200
    r.raise_for_status()
    reply = r.json()

    print(reply)
    
    # this also fails as it has the group role 3 times (!) in jack's roles (3rd use of the API token)
    #assert len(reply[0]['roles']) == 1
    #assert reply[0]['name'] == 'jack'
    # make it fail to get print statements
    assert group_role.name not in reply[0]['roles']
```
> db issue appears to be fixed by #3475 

It looks good to me and I've tried it out but I'm still getting the 'db is locked' error when running all role tests.  

Resolving patch:
diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py
--- a/jupyterhub/roles.py
+++ b/jupyterhub/roles.py
@@ -164,13 +164,12 @@ def expand_roles_to_scopes(orm_object):
     if not isinstance(orm_object, orm.Base):
         raise TypeError(f"Only orm objects allowed, got {orm_object}")
 
-    pass_roles = orm_object.roles
+    pass_roles = []
+    pass_roles.extend(orm_object.roles)
 
     if isinstance(orm_object, orm.User):
-        groups_roles = []
         for group in orm_object.groups:
-            groups_roles.extend(group.roles)
-        pass_roles.extend(groups_roles)
+            pass_roles.extend(group.roles)
 
     scopes = _get_subscopes(*pass_roles, owner=orm_object)
 


Test patch:
diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py
--- a/jupyterhub/tests/test_roles.py
+++ b/jupyterhub/tests/test_roles.py
@@ -1046,3 +1046,71 @@ async def test_oauth_allowed_roles(app, create_temp_role):
     app_service = app.services[0]
     assert app_service['name'] == 'oas1'
     assert set(app_service['oauth_roles']) == set(allowed_roles)
+
+
+async def test_user_group_roles(app, create_temp_role):
+    user = add_user(app.db, app, name='jack')
+    another_user = add_user(app.db, app, name='jill')
+
+    group = orm.Group.find(app.db, name='A')
+    if not group:
+        group = orm.Group(name='A')
+        app.db.add(group)
+        app.db.commit()
+
+    if group not in user.groups:
+        user.groups.append(group)
+        app.db.commit()
+
+    if group not in another_user.groups:
+        another_user.groups.append(group)
+        app.db.commit()
+
+    group_role = orm.Role.find(app.db, 'student-a')
+    if not group_role:
+        create_temp_role(['read:groups!group=A'], 'student-a')
+        roles.grant_role(app.db, group, rolename='student-a')
+        group_role = orm.Role.find(app.db, 'student-a')
+
+    # repeat check to ensure group roles don't get added to the user at all
+    # regression test for #3472
+    roles_before = list(user.roles)
+    for i in range(3):
+        roles.expand_roles_to_scopes(user.orm_user)
+        user_roles = list(user.roles)
+        assert user_roles == roles_before
+
+    # jack's API token
+    token = user.new_api_token()
+
+    headers = {'Authorization': 'token %s' % token}
+    r = await api_request(app, 'users', method='get', headers=headers)
+    assert r.status_code == 200
+    r.raise_for_status()
+    reply = r.json()
+
+    print(reply)
+
+    assert len(reply[0]['roles']) == 1
+    assert reply[0]['name'] == 'jack'
+    assert group_role.name not in reply[0]['roles']
+
+    headers = {'Authorization': 'token %s' % token}
+    r = await api_request(app, 'groups', method='get', headers=headers)
+    assert r.status_code == 200
+    r.raise_for_status()
+    reply = r.json()
+
+    print(reply)
+
+    headers = {'Authorization': 'token %s' % token}
+    r = await api_request(app, 'users', method='get', headers=headers)
+    assert r.status_code == 200
+    r.raise_for_status()
+    reply = r.json()
+
+    print(reply)
+
+    assert len(reply[0]['roles']) == 1
+    assert reply[0]['name'] == 'jack'
+    assert group_role.name not in reply[0]['roles']