diff --git a/tests/test_groups.py b/tests/test_groups.py
index dc0294f5d03897ce3c053ccc81ee66170d72a1f2_dGVzdHMvdGVzdF9ncm91cHMucHk=..bec7cc14c4da8260eaa318619822c036e86479be_dGVzdHMvdGVzdF9ncm91cHMucHk= 100644
--- a/tests/test_groups.py
+++ b/tests/test_groups.py
@@ -3,5 +3,6 @@
     needs,
     unique_name,
 )
+from .utils.access import GroupAccess
 from .utils.group import Group
 from .utils.project import Project
@@ -6,5 +7,6 @@
 from .utils.group import Group
 from .utils.project import Project
+from .utils.user import User
 
 from .test_push import prepare_simple_repo
 
@@ -80,6 +82,32 @@
             }
 
 
+def test_custom_attributes_perms(heptapod):
+    """Ensuring that these are as private as we think they are."""
+    owner = heptapod.get_user('test_basic')
+    admin = heptapod.get_user('root')
+    with Group.api_create(heptapod, unique_name('test_group'),
+                          user_name=owner.name) as group:
+        # first check that custom attributes do work
+        group.api_set_custom_attribute('location', 'secret lair',
+                                       user=admin)
+        assert group.api_get_custom_attribute('location',
+                                              user=admin) == 'secret lair'
+
+        def assert_forbidden(user):
+            resp = group.api_get_custom_attribute('location', user=user,
+                                                  check=False)
+            assert resp.status_code == 403
+
+        with User.create(heptapod,
+                         unique_name('user_custom_attr')) as user:
+            for level in GroupAccess:
+                group.grant_member_access(user, level)
+                # Even OWNER is forbidden. If that were to
+                # change it probably wouldn't be really shocking.
+                assert_forbidden(owner)
+
+
 @needs.services
 @parametrize('hashed_storage', [True, False])
 def test_subgroups(heptapod, hashed_storage):
diff --git a/tests/test_projects.py b/tests/test_projects.py
index dc0294f5d03897ce3c053ccc81ee66170d72a1f2_dGVzdHMvdGVzdF9wcm9qZWN0cy5weQ==..bec7cc14c4da8260eaa318619822c036e86479be_dGVzdHMvdGVzdF9wcm9qZWN0cy5weQ== 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -11,5 +11,6 @@
     unique_name,
     needs,
 )
+from .utils.access import GroupAccess
 from .utils.project import (
     Project,
@@ -14,6 +15,5 @@
 from .utils.project import (
     Project,
-    ProjectAccess,
 )
 from .utils.group import Group
 from .utils.hg import LocalRepo
@@ -349,9 +349,8 @@
     if how == 'bumped-quota':
         external_user.edit(projects_limit=1)
     elif how == 'group-member':
-        resp = test_group.api_add_member(external_user,
-                                         ProjectAccess.DEVELOPER)
-        assert resp.status_code == 201
+        test_group.grant_member_access(external_user,
+                                       GroupAccess.DEVELOPER)
 
     # Now we have this big link in the middle of the page
     webdriver.get(heptapod.url)
diff --git a/tests/utils/access.py b/tests/utils/access.py
new file mode 100644
index 0000000000000000000000000000000000000000..bec7cc14c4da8260eaa318619822c036e86479be_dGVzdHMvdXRpbHMvYWNjZXNzLnB5
--- /dev/null
+++ b/tests/utils/access.py
@@ -0,0 +1,34 @@
+import enum
+
+
+class Access(enum.IntEnum):
+    """Union of all possible values for Projects and Groups."""
+    NO_ACCESS = 0
+    MINIMAL = 5  # Introduced in GitLab 13.5
+    GUEST = 10
+    REPORTER = 20
+    DEVELOPER = 30
+    MAINTAINER = 40
+    OWNER = 50
+
+
+class ProjectAccess(enum.IntEnum):
+    """Subset of Access suitable to set on a Project.
+
+    - `OWNER` is only settable on Groups.
+    - `NO_ACCESS` is probably not settable on Projects
+    - `MINIMAL` is to be determined
+    """
+    GUEST = Access.GUEST
+    REPORTER = Access.REPORTER
+    DEVELOPER = Access.DEVELOPER
+    MAINTAINER = Access.MAINTAINER
+
+
+class GroupAccess(enum.IntEnum):
+    """Subset of Access suitable to set on a Group"""
+    GUEST = Access.GUEST
+    REPORTER = Access.REPORTER
+    DEVELOPER = Access.DEVELOPER
+    MAINTAINER = Access.MAINTAINER
+    OWNER = Access.OWNER
diff --git a/tests/utils/group.py b/tests/utils/group.py
index dc0294f5d03897ce3c053ccc81ee66170d72a1f2_dGVzdHMvdXRpbHMvZ3JvdXAucHk=..bec7cc14c4da8260eaa318619822c036e86479be_dGVzdHMvdXRpbHMvZ3JvdXAucHk= 100644
--- a/tests/utils/group.py
+++ b/tests/utils/group.py
@@ -1,6 +1,8 @@
 import attr
 import requests
 
+from .access import GroupAccess
+
 
 class NameSpace:
 
@@ -80,11 +82,12 @@
         user_name = self.owner_name if self.owner_name is not None else 'root'
         return {'Private-Token': self.heptapod.users[user_name].token}
 
-    def api_get(self):
-        return requests.get(self.api_url, headers=self.private_token())
+    def api_get(self, subpath=''):
+        return requests.get('/'.join((self.api_url, subpath)),
+                            headers=self.private_token())
 
     def api_post(self, subpath='', **params):
         return requests.post('/'.join((self.api_url, subpath)),
                              headers=self.private_token(),
                              data=params)
 
@@ -85,13 +88,13 @@
 
     def api_post(self, subpath='', **params):
         return requests.post('/'.join((self.api_url, subpath)),
                              headers=self.private_token(),
                              data=params)
 
-    def api_add_member(self, user, access_level):
-        return self.api_post(subpath='members',
-                             user_id=user.id,
-                             access_level=access_level)
+    def api_put(self, subpath='', **params):
+        return requests.put('/'.join((self.api_url, subpath)),
+                            headers=self.private_token(),
+                            data=params)
 
     @classmethod
     def api_search(cls, heptapod, group_name, user_name='root'):
@@ -113,6 +116,59 @@
         resp = requests.delete(self.api_url, headers=self.private_token())
         assert resp.status_code in (202, 204)
 
+    def grant_member_access(self, user, level):
+        """Grant given user the given access level.
+
+        It doesn't matter whether the user is already a member or not: this
+        method abstracts over it.
+
+        This method is idempotent.
+
+        TODO factorize with Project (as surely is done in the Rails app)
+        """
+        assert level in GroupAccess
+
+        user_id = user.id
+        resp = self.api_get(subpath='members/%d' % user_id)
+        if resp.status_code == 404:
+            subpath = 'members'
+            meth = self.api_post
+        else:
+            subpath = 'members/%d' % user_id
+            meth = self.api_put
+
+        resp = meth(subpath=subpath,
+                    user_id=user_id,
+                    access_level=int(level))
+        assert resp.status_code < 400
+
+    def custom_attribute_api_url(self, key):
+        return '/'.join((self.api_url, 'custom_attributes', key))
+
+    def api_set_custom_attribute(self, key, value, user=None):
+        if user is None:
+            user = self.heptapod.get_user('root')
+
+        resp = requests.put(self.custom_attribute_api_url(key),
+                            headers={'Private-Token': user.token},
+                            data=dict(value=value))
+
+        assert resp.status_code == 200
+        return resp.json()
+
+    def api_get_custom_attribute(self, key, check=True, user=None):
+        if user is None:
+            user = self.heptapod.get_user('root')
+
+        resp = requests.get(self.custom_attribute_api_url(key),
+                            headers={'Private-Token': user.token})
+
+        if not check:
+            return resp
+
+        assert resp.status_code < 400
+        return resp.json()['value']
+
     def fs_path(self):
         return '/'.join((self.heptapod.repositories_root, self.full_path))
 
diff --git a/tests/utils/project.py b/tests/utils/project.py
index dc0294f5d03897ce3c053ccc81ee66170d72a1f2_dGVzdHMvdXRpbHMvcHJvamVjdC5weQ==..bec7cc14c4da8260eaa318619822c036e86479be_dGVzdHMvdXRpbHMvcHJvamVjdC5weQ== 100644
--- a/tests/utils/project.py
+++ b/tests/utils/project.py
@@ -18,6 +18,7 @@
 from selenium.webdriver.support.ui import WebDriverWait
 
 from . import wait_assert
+from .access import ProjectAccess
 from .constants import DATA_DIR
 from .hg import LocalRepo
 from .group import UserNameSpace
@@ -27,15 +28,6 @@
 logger = logging.getLogger(__name__)
 
 
-class ProjectAccess:
-    NO_ACCESS = 0
-    GUEST = 10
-    REPORTER = 20
-    DEVELOPER = 30
-    MAINTAINER = 40
-    OWNER = 50
-
-
 def extract_gitlab_branch_titles(branches):
     return {name: info['commit']['title']
             for name, info in branches.items()}
@@ -724,6 +716,8 @@
 
         This method is idempotent.
         """
+        assert level in ProjectAccess
+
         user_id = self.heptapod.get_user(user_name).id
         resp = self.owner_api_get(subpath='members/%d' % user_id)
         if resp.status_code == 404:
@@ -734,7 +728,7 @@
             meth = self.owner_api_put
 
         resp = meth(subpath=subpath,
-                    data=dict(user_id=user_id, access_level=level))
+                    data=dict(user_id=user_id, access_level=int(level)))
         assert resp.status_code < 400
 
     def load_tarball(self, tarball_path):