Repository: jupyterhub/jupyterhub

Issue:
load_roles referencing users and groups: not found users are created, not found groups cause errors - is it wanted behavior?
I was surprised that users were automatically created without a warning, but groups not found caused an error. I think it could be okay, but I'm not sure and wanted to raise it for a consideration - is there something we want to change about this?


```python
c.JupyterHub.load_roles = [
    {
        "name": "test-role-1:",
        "description": "Access to users' information and group membership",
        "scopes": ["users", "groups"],
        "users": ["cyclops", "gandalf"],
        "services": ["test"],
        # adding a group not defined would raise an error
        # there is a load_groups function but not a load_users function though
        "groups": [],
    }
]
```

```
[I 2021-09-26 01:03:16.454 JupyterHub app:2095] Found unexisting users gandalf in role definition test-role-1
[I 2021-09-26 01:03:16.455 JupyterHub app:1963] Creating user gandalf
```


Discussion:
My initial expectation is that neither users nor groups are automatically created just because they're mentioned in a role. Ideally they'd be lazy loaded, so e.g. if a user is permitted to login by external oauth but has never logged in before then that role would automatically take effect when they first logged in. Alternatively the role could ignore (or warn about) unknown users/groups. I'm interested to hear other perspectives though.
I do think groups and users should probably be handled consistently, at the very least.

> Ideally they'd be lazy loaded, 

That would make sense in the abstract, but in practice it can't work that way because role membership is currently a db relationship - a user can't have a role if the User record isn't in the database. I think it would be a lot more complicated to preserve the list of users assigned roles that don't yet exist, and do new role assignments if/when they eventually show up, than it is to create populate the user row immediately. That simplifies things greatly and is very cheap.

I think it's a tricky balance between redundancy of overly-verbose config (where users must all appear in *both* the `allowed_users` set *and* in any role the user is assigned). But I suppose separating role assignment from user creation may have some benefit that I don't see.

One reason users are created when declared in roles is that the `allowed_users` set is now basically an alias for populating the users role. The only difference is that for backward-compatibility purposes, `allowed_users` is an *initial* set which can be expanded via API requests (e.g. the admin page), whereas if you specify the `user` role membership list explicitly, any user not in that list *loses* that role on startup. We've never been able to do permission revocation on startup before.

As it is now, there are also cases that cannot be supported if users assigned to roles *must* first be declared separately, for example:

- no allowed_users set (any successful login is allowed), and
- some users are assigned to a special role admins / teachers / etc.

That common pattern (almost every Hub I've deployed) cannot be accomplished unless we also add a *separate* `load_users` list that's almost the same as `allowed_users`, but is purely declaring users to exist without granting any permissions. I see this as redundant with populating from role assignments, since all it seems to accomplish is making sure every config has every username at least twice.

So my perspective is that a username or group appearing in a role should result in creating that user or group in the database.

But I'm open to other suggestions! Feel free to sketch what you'd *like* a config file to look like, and we can try to make it work.


Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -2109,7 +2109,7 @@ async def init_role_assignment(self):
                                 )
                         Class = orm.get_class(kind)
                         orm_obj = Class.find(db, bname)
-                        if orm_obj:
+                        if orm_obj is not None:
                             orm_role_bearers.append(orm_obj)
                         else:
                             app_log.info(
@@ -2118,6 +2118,11 @@ async def init_role_assignment(self):
                             if kind == 'users':
                                 orm_obj = await self._get_or_create_user(bname)
                                 orm_role_bearers.append(orm_obj)
+                            elif kind == 'groups':
+                                group = orm.Group(name=bname)
+                                db.add(group)
+                                db.commit()
+                                orm_role_bearers.append(group)
                             else:
                                 raise ValueError(
                                     f"{kind} {bname} defined in config role definition {predef_role['name']} but not present in database"


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
@@ -578,7 +578,7 @@ async def test_load_roles_groups(tmpdir, request):
             'name': 'head',
             'description': 'Whole user access',
             'scopes': ['users', 'admin:users'],
-            'groups': ['group3'],
+            'groups': ['group3', "group4"],
         },
     ]
     kwargs = {'load_groups': groups_to_load, 'load_roles': roles_to_load}
@@ -598,11 +598,13 @@ async def test_load_roles_groups(tmpdir, request):
     group1 = orm.Group.find(db, name='group1')
     group2 = orm.Group.find(db, name='group2')
     group3 = orm.Group.find(db, name='group3')
+    group4 = orm.Group.find(db, name='group4')
 
     # test group roles
     assert group1.roles == []
     assert group2 in assist_role.groups
     assert group3 in head_role.groups
+    assert group4 in head_role.groups
 
     # delete the test roles
     for role in roles_to_load: