api/rest.py | 78 +++++++++++++++++++++++++++++++++++----------- tests/test_rest.py | 11 +++++++ 2 files changed, 70 insertions(+), 19 deletions(-)
Until now, the REST API didn't really have a good permission system. In
particular, it did not know about user groups. We will need to allow
the "importer" group to add messages to any project, so it's time to
improve the permissions. Similar to the old API, all the knowledge of
permissions is encapsulated in one class, in this case a DRF Permission
subclass. The new class replaces the old IsAdminUserOrReadOnly and
IsMaintainerUserOrReadOnly permissions.
---
v1->v2: introduce has_message_permission; check obj class before invoking
has_{project,message}_permission; check presence of view.project
instead of kwarg
api/rest.py | 78 +++++++++++++++++++++++++++++++++++-----------
tests/test_rest.py | 11 +++++++
2 files changed, 70 insertions(+), 19 deletions(-)
diff --git a/api/rest.py b/api/rest.py
index 9e280d4..a6f9deb 100644
--- a/api/rest.py
+++ b/api/rest.py
@@ -30,23 +30,53 @@ SEARCH_PARAM = 'q'
# patchew-specific permission classes
-class IsAdminUserOrReadOnly(permissions.BasePermission):
+class PatchewPermission(permissions.BasePermission):
"""
- Allows access only to admin users.
+ Generic code to lookup for permissions based on message and project
+ objects. If the view also has a "project" property, it should
+ returns an api.models.Project, and has_permission will check that
+ property too.
+
+ Subclasses can override the methods, or specify a set of groups that
+ are granted authorization independent of object permissions.
"""
+
+ allowed_groups = ()
+
+ def is_superuser(self, request):
+ return request.user and request.user.is_superuser
+
+ def has_project_permission(self, request, view, obj):
+ return obj.maintained_by(request.user)
+
+ def has_message_permission(self, request, view, obj):
+ return obj.project.maintained_by(request.user)
+
+ def has_group_permission(self, request, view):
+ for grp in request.user.groups.all():
+ if grp.name in self.allowed_groups:
+ return True
+ return False
+
+ def has_generic_permission(self, request, view):
+ return (request.method in permissions.SAFE_METHODS) or \
+ self.is_superuser(request) or \
+ self.has_group_permission(request, view)
+
def has_permission(self, request, view):
- return request.method in permissions.SAFE_METHODS or \
- (request.user and request.user.is_superuser)
+ return self.has_generic_permission(request, view) or \
+ (hasattr(view, 'project') and view.project and \
+ self.has_project_permission(request, view, view.project))
-class IsMaintainerOrReadOnly(permissions.BasePermission):
- """
- Allows access only to admin users or maintainers.
- """
def has_object_permission(self, request, view, obj):
- if isinstance(obj, Message):
- obj = obj.project
- return request.method in permissions.SAFE_METHODS or \
- obj.maintained_by(request.user)
+ return self.has_generic_permission(request, view) or \
+ (isinstance(obj, Message) and \
+ self.has_message_permission(request, view, obj)) or \
+ (isinstance(obj, Project) and \
+ self.has_project_permission(request, view, obj))
+
+class ImportPermission(PatchewPermission):
+ allowed_groups = ('importers',)
# pluggable field for plugin support
@@ -87,7 +117,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer):
class UsersViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('id')
serializer_class = UserSerializer
- permission_classes = (IsAdminUserOrReadOnly,)
+ permission_classes = (PatchewPermission,)
# Projects
@@ -110,7 +140,7 @@ class ProjectSerializer(serializers.HyperlinkedModelSerializer):
class ProjectsViewSet(viewsets.ModelViewSet):
queryset = Project.objects.all().order_by('id')
serializer_class = ProjectSerializer
- permission_classes = (IsMaintainerOrReadOnly,)
+ permission_classes = (PatchewPermission,)
# Common classes for series and messages
@@ -152,7 +182,7 @@ class BaseMessageSerializer(serializers.ModelSerializer):
class BaseMessageViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
serializer_class = BaseMessageSerializer
queryset = Message.objects.all()
- permission_classes = ()
+ permission_classes = (ImportPermission,)
lookup_field = 'message_id'
lookup_value_regex = '[^/]+'
@@ -161,11 +191,21 @@ class ProjectMessagesViewSetMixin(mixins.RetrieveModelMixin):
def get_queryset(self):
return self.queryset.filter(project=self.kwargs['projects_pk'])
- def get_serializer_context(self):
+ @property
+ def project(self):
+ if hasattr(self, '__project'):
+ return self.__project
try:
- return {'project': Project.objects.get(id=self.kwargs['projects_pk']), 'request': self.request}
- except:
+ self.__project = Project.objects.get(id=self.kwargs['projects_pk'])
+ except:
+ self.__project = None
+ return self.__project
+
+ def get_serializer_context(self):
+ if self.project is None:
return Http404
+ return {'project': self.project, 'request': self.request}
+
# Series
class ReplySerializer(BaseMessageSerializer):
@@ -248,7 +288,6 @@ class SeriesViewSet(BaseMessageViewSet):
queryset = Message.objects.filter(is_series_head=True).order_by('-last_reply_date')
filter_backends = (PatchewSearchFilter,)
search_fields = (SEARCH_PARAM,)
- permission_classes = (IsMaintainerOrReadOnly,)
class ProjectSeriesViewSet(ProjectMessagesViewSetMixin,
@@ -376,6 +415,7 @@ class ResultSerializerFull(ResultSerializer):
class ResultsViewSet(viewsets.ViewSet, generics.GenericAPIView):
lookup_field = 'name'
lookup_value_regex = '[^/]+'
+ permission_classes = (PatchewPermission,)
def get_serializer_class(self, *args, **kwargs):
if self.lookup_field in self.kwargs:
diff --git a/tests/test_rest.py b/tests/test_rest.py
index 6a9c332..7896e8a 100755
--- a/tests/test_rest.py
+++ b/tests/test_rest.py
@@ -84,10 +84,18 @@ class RestTest(PatchewTestCase):
self.assertEquals(resp.data['mailing_list'], "qemu-block@nongnu.org")
self.assertEquals(resp.data['parent_project'], self.PROJECT_BASE)
+ def test_project_post_no_login(self):
+ data = {
+ 'name': 'keycodemapdb',
+ }
+ resp = self.api_client.post(self.REST_BASE + 'projects/', data=data)
+ self.assertEquals(resp.status_code, 403)
+
def test_project_post_minimal(self):
data = {
'name': 'keycodemapdb',
}
+ self.api_client.login(username=self.user, password=self.password)
resp = self.api_client.post(self.REST_BASE + 'projects/', data=data)
self.assertEquals(resp.status_code, 201)
self.assertEquals(resp.data['resource_uri'].startswith(self.REST_BASE + 'projects/'), True)
@@ -97,6 +105,7 @@ class RestTest(PatchewTestCase):
self.assertEquals(resp.data['name'], data['name'])
def test_project_post(self):
+ self.api_client.login(username=self.user, password=self.password)
data = {
'name': 'keycodemapdb',
'mailing_list': 'qemu-devel@nongnu.org',
@@ -267,6 +276,7 @@ class RestTest(PatchewTestCase):
dp = self.get_data_path("0022-another-simple-patch.json.gz")
with open(dp, "r") as f:
data = f.read()
+ self.api_client.login(username=self.user, password=self.password)
resp = self.api_client.post(self.PROJECT_BASE + "messages/", data, content_type='application/json')
self.assertEqual(resp.status_code, 201)
resp_get = self.api_client.get(self.PROJECT_BASE + "messages/20171023201055.21973-11-andrew.smirnov@gmail.com/")
@@ -278,6 +288,7 @@ class RestTest(PatchewTestCase):
dp = self.get_data_path("0004-multiple-patch-reviewed.mbox.gz")
with open(dp, "r") as f:
data = f.read()
+ self.api_client.login(username=self.user, password=self.password)
resp = self.api_client.post(self.PROJECT_BASE + "messages/", data, content_type='message/rfc822')
self.assertEqual(resp.status_code, 201)
resp_get = self.api_client.get(self.PROJECT_BASE + "messages/1469192015-16487-1-git-send-email-berrange@redhat.com/")
--
2.17.0
_______________________________________________
Patchew-devel mailing list
Patchew-devel@redhat.com
https://www.redhat.com/mailman/listinfo/patchew-devel
On Thu, 05/10 13:05, Paolo Bonzini wrote: > Until now, the REST API didn't really have a good permission system. In > particular, it did not know about user groups. We will need to allow > the "importer" group to add messages to any project, so it's time to > improve the permissions. Similar to the old API, all the knowledge of > permissions is encapsulated in one class, in this case a DRF Permission > subclass. The new class replaces the old IsAdminUserOrReadOnly and > IsMaintainerUserOrReadOnly permissions. > --- > v1->v2: introduce has_message_permission; check obj class before invoking > has_{project,message}_permission; check presence of view.project > instead of kwarg > > api/rest.py | 78 +++++++++++++++++++++++++++++++++++----------- > tests/test_rest.py | 11 +++++++ > 2 files changed, 70 insertions(+), 19 deletions(-) > > diff --git a/api/rest.py b/api/rest.py > index 9e280d4..a6f9deb 100644 > --- a/api/rest.py > +++ b/api/rest.py > @@ -30,23 +30,53 @@ SEARCH_PARAM = 'q' > > # patchew-specific permission classes > > -class IsAdminUserOrReadOnly(permissions.BasePermission): > +class PatchewPermission(permissions.BasePermission): > """ > - Allows access only to admin users. > + Generic code to lookup for permissions based on message and project > + objects. If the view also has a "project" property, it should > + returns an api.models.Project, and has_permission will check that s/returns/return/ Reviewed-by: Fam Zheng <famz@redhat.com> > + property too. > + > + Subclasses can override the methods, or specify a set of groups that > + are granted authorization independent of object permissions. > """ > + > + allowed_groups = () > + > + def is_superuser(self, request): > + return request.user and request.user.is_superuser > + > + def has_project_permission(self, request, view, obj): > + return obj.maintained_by(request.user) > + > + def has_message_permission(self, request, view, obj): > + return obj.project.maintained_by(request.user) > + > + def has_group_permission(self, request, view): > + for grp in request.user.groups.all(): > + if grp.name in self.allowed_groups: > + return True > + return False > + > + def has_generic_permission(self, request, view): > + return (request.method in permissions.SAFE_METHODS) or \ > + self.is_superuser(request) or \ > + self.has_group_permission(request, view) > + > def has_permission(self, request, view): > - return request.method in permissions.SAFE_METHODS or \ > - (request.user and request.user.is_superuser) > + return self.has_generic_permission(request, view) or \ > + (hasattr(view, 'project') and view.project and \ > + self.has_project_permission(request, view, view.project)) > > -class IsMaintainerOrReadOnly(permissions.BasePermission): > - """ > - Allows access only to admin users or maintainers. > - """ > def has_object_permission(self, request, view, obj): > - if isinstance(obj, Message): > - obj = obj.project > - return request.method in permissions.SAFE_METHODS or \ > - obj.maintained_by(request.user) > + return self.has_generic_permission(request, view) or \ > + (isinstance(obj, Message) and \ > + self.has_message_permission(request, view, obj)) or \ > + (isinstance(obj, Project) and \ > + self.has_project_permission(request, view, obj)) > + > +class ImportPermission(PatchewPermission): > + allowed_groups = ('importers',) > > # pluggable field for plugin support > > @@ -87,7 +117,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer): > class UsersViewSet(viewsets.ModelViewSet): > queryset = User.objects.all().order_by('id') > serializer_class = UserSerializer > - permission_classes = (IsAdminUserOrReadOnly,) > + permission_classes = (PatchewPermission,) > > # Projects > > @@ -110,7 +140,7 @@ class ProjectSerializer(serializers.HyperlinkedModelSerializer): > class ProjectsViewSet(viewsets.ModelViewSet): > queryset = Project.objects.all().order_by('id') > serializer_class = ProjectSerializer > - permission_classes = (IsMaintainerOrReadOnly,) > + permission_classes = (PatchewPermission,) > > # Common classes for series and messages > > @@ -152,7 +182,7 @@ class BaseMessageSerializer(serializers.ModelSerializer): > class BaseMessageViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): > serializer_class = BaseMessageSerializer > queryset = Message.objects.all() > - permission_classes = () > + permission_classes = (ImportPermission,) > lookup_field = 'message_id' > lookup_value_regex = '[^/]+' > > @@ -161,11 +191,21 @@ class ProjectMessagesViewSetMixin(mixins.RetrieveModelMixin): > def get_queryset(self): > return self.queryset.filter(project=self.kwargs['projects_pk']) > > - def get_serializer_context(self): > + @property > + def project(self): > + if hasattr(self, '__project'): > + return self.__project > try: > - return {'project': Project.objects.get(id=self.kwargs['projects_pk']), 'request': self.request} > - except: > + self.__project = Project.objects.get(id=self.kwargs['projects_pk']) > + except: > + self.__project = None > + return self.__project > + > + def get_serializer_context(self): > + if self.project is None: > return Http404 > + return {'project': self.project, 'request': self.request} > + > # Series > > class ReplySerializer(BaseMessageSerializer): > @@ -248,7 +288,6 @@ class SeriesViewSet(BaseMessageViewSet): > queryset = Message.objects.filter(is_series_head=True).order_by('-last_reply_date') > filter_backends = (PatchewSearchFilter,) > search_fields = (SEARCH_PARAM,) > - permission_classes = (IsMaintainerOrReadOnly,) > > > class ProjectSeriesViewSet(ProjectMessagesViewSetMixin, > @@ -376,6 +415,7 @@ class ResultSerializerFull(ResultSerializer): > class ResultsViewSet(viewsets.ViewSet, generics.GenericAPIView): > lookup_field = 'name' > lookup_value_regex = '[^/]+' > + permission_classes = (PatchewPermission,) > > def get_serializer_class(self, *args, **kwargs): > if self.lookup_field in self.kwargs: > diff --git a/tests/test_rest.py b/tests/test_rest.py > index 6a9c332..7896e8a 100755 > --- a/tests/test_rest.py > +++ b/tests/test_rest.py > @@ -84,10 +84,18 @@ class RestTest(PatchewTestCase): > self.assertEquals(resp.data['mailing_list'], "qemu-block@nongnu.org") > self.assertEquals(resp.data['parent_project'], self.PROJECT_BASE) > > + def test_project_post_no_login(self): > + data = { > + 'name': 'keycodemapdb', > + } > + resp = self.api_client.post(self.REST_BASE + 'projects/', data=data) > + self.assertEquals(resp.status_code, 403) > + > def test_project_post_minimal(self): > data = { > 'name': 'keycodemapdb', > } > + self.api_client.login(username=self.user, password=self.password) > resp = self.api_client.post(self.REST_BASE + 'projects/', data=data) > self.assertEquals(resp.status_code, 201) > self.assertEquals(resp.data['resource_uri'].startswith(self.REST_BASE + 'projects/'), True) > @@ -97,6 +105,7 @@ class RestTest(PatchewTestCase): > self.assertEquals(resp.data['name'], data['name']) > > def test_project_post(self): > + self.api_client.login(username=self.user, password=self.password) > data = { > 'name': 'keycodemapdb', > 'mailing_list': 'qemu-devel@nongnu.org', > @@ -267,6 +276,7 @@ class RestTest(PatchewTestCase): > dp = self.get_data_path("0022-another-simple-patch.json.gz") > with open(dp, "r") as f: > data = f.read() > + self.api_client.login(username=self.user, password=self.password) > resp = self.api_client.post(self.PROJECT_BASE + "messages/", data, content_type='application/json') > self.assertEqual(resp.status_code, 201) > resp_get = self.api_client.get(self.PROJECT_BASE + "messages/20171023201055.21973-11-andrew.smirnov@gmail.com/") > @@ -278,6 +288,7 @@ class RestTest(PatchewTestCase): > dp = self.get_data_path("0004-multiple-patch-reviewed.mbox.gz") > with open(dp, "r") as f: > data = f.read() > + self.api_client.login(username=self.user, password=self.password) > resp = self.api_client.post(self.PROJECT_BASE + "messages/", data, content_type='message/rfc822') > self.assertEqual(resp.status_code, 201) > resp_get = self.api_client.get(self.PROJECT_BASE + "messages/1469192015-16487-1-git-send-email-berrange@redhat.com/") > -- > 2.17.0 > _______________________________________________ Patchew-devel mailing list Patchew-devel@redhat.com https://www.redhat.com/mailman/listinfo/patchew-devel
Until now, the REST API didn't really have a good permission system. In
particular, it did not know about user groups. We will need to allow
the "importer" group to add messages to any project, so it's time to
improve the permissions. Similar to the old API, all the knowledge of
permissions is encapsulated in one class, in this case a DRF Permission
subclass. The new class replaces the old IsAdminUserOrReadOnly and
IsMaintainerUserOrReadOnly permissions.
---
api/rest.py | 78 +++++++++++++++++++++++++++++++++++-----------
tests/test_rest.py | 11 +++++++
2 files changed, 70 insertions(+), 19 deletions(-)
diff --git a/api/rest.py b/api/rest.py
index 9e280d4..a6f9deb 100644
--- a/api/rest.py
+++ b/api/rest.py
@@ -30,23 +30,53 @@ SEARCH_PARAM = 'q'
# patchew-specific permission classes
-class IsAdminUserOrReadOnly(permissions.BasePermission):
+class PatchewPermission(permissions.BasePermission):
"""
- Allows access only to admin users.
+ Generic code to lookup for permissions based on message and project
+ objects. If the view also has a "project" property, it should
+ returns an api.models.Project, and has_permission will check that
+ property too.
+
+ Subclasses can override the methods, or specify a set of groups that
+ are granted authorization independent of object permissions.
"""
+
+ allowed_groups = ()
+
+ def is_superuser(self, request):
+ return request.user and request.user.is_superuser
+
+ def has_project_permission(self, request, view, obj):
+ return obj.maintained_by(request.user)
+
+ def has_message_permission(self, request, view, obj):
+ return obj.project.maintained_by(request.user)
+
+ def has_group_permission(self, request, view):
+ for grp in request.user.groups.all():
+ if grp.name in self.allowed_groups:
+ return True
+ return False
+
+ def has_generic_permission(self, request, view):
+ return (request.method in permissions.SAFE_METHODS) or \
+ self.is_superuser(request) or \
+ self.has_group_permission(request, view)
+
def has_permission(self, request, view):
- return request.method in permissions.SAFE_METHODS or \
- (request.user and request.user.is_superuser)
+ return self.has_generic_permission(request, view) or \
+ (hasattr(view, 'project') and view.project and \
+ self.has_project_permission(request, view, view.project))
-class IsMaintainerOrReadOnly(permissions.BasePermission):
- """
- Allows access only to admin users or maintainers.
- """
def has_object_permission(self, request, view, obj):
- if isinstance(obj, Message):
- obj = obj.project
- return request.method in permissions.SAFE_METHODS or \
- obj.maintained_by(request.user)
+ return self.has_generic_permission(request, view) or \
+ (isinstance(obj, Message) and \
+ self.has_message_permission(request, view, obj)) or \
+ (isinstance(obj, Project) and \
+ self.has_project_permission(request, view, obj))
+
+class ImportPermission(PatchewPermission):
+ allowed_groups = ('importers',)
# pluggable field for plugin support
@@ -87,7 +117,7 @@ class UserSerializer(serializers.HyperlinkedModelSerializer):
class UsersViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('id')
serializer_class = UserSerializer
- permission_classes = (IsAdminUserOrReadOnly,)
+ permission_classes = (PatchewPermission,)
# Projects
@@ -110,7 +140,7 @@ class ProjectSerializer(serializers.HyperlinkedModelSerializer):
class ProjectsViewSet(viewsets.ModelViewSet):
queryset = Project.objects.all().order_by('id')
serializer_class = ProjectSerializer
- permission_classes = (IsMaintainerOrReadOnly,)
+ permission_classes = (PatchewPermission,)
# Common classes for series and messages
@@ -152,7 +182,7 @@ class BaseMessageSerializer(serializers.ModelSerializer):
class BaseMessageViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
serializer_class = BaseMessageSerializer
queryset = Message.objects.all()
- permission_classes = ()
+ permission_classes = (ImportPermission,)
lookup_field = 'message_id'
lookup_value_regex = '[^/]+'
@@ -161,11 +191,21 @@ class ProjectMessagesViewSetMixin(mixins.RetrieveModelMixin):
def get_queryset(self):
return self.queryset.filter(project=self.kwargs['projects_pk'])
- def get_serializer_context(self):
+ @property
+ def project(self):
+ if hasattr(self, '__project'):
+ return self.__project
try:
- return {'project': Project.objects.get(id=self.kwargs['projects_pk']), 'request': self.request}
- except:
+ self.__project = Project.objects.get(id=self.kwargs['projects_pk'])
+ except:
+ self.__project = None
+ return self.__project
+
+ def get_serializer_context(self):
+ if self.project is None:
return Http404
+ return {'project': self.project, 'request': self.request}
+
# Series
class ReplySerializer(BaseMessageSerializer):
@@ -248,7 +288,6 @@ class SeriesViewSet(BaseMessageViewSet):
queryset = Message.objects.filter(is_series_head=True).order_by('-last_reply_date')
filter_backends = (PatchewSearchFilter,)
search_fields = (SEARCH_PARAM,)
- permission_classes = (IsMaintainerOrReadOnly,)
class ProjectSeriesViewSet(ProjectMessagesViewSetMixin,
@@ -376,6 +415,7 @@ class ResultSerializerFull(ResultSerializer):
class ResultsViewSet(viewsets.ViewSet, generics.GenericAPIView):
lookup_field = 'name'
lookup_value_regex = '[^/]+'
+ permission_classes = (PatchewPermission,)
def get_serializer_class(self, *args, **kwargs):
if self.lookup_field in self.kwargs:
diff --git a/tests/test_rest.py b/tests/test_rest.py
index 6a9c332..7896e8a 100755
--- a/tests/test_rest.py
+++ b/tests/test_rest.py
@@ -84,10 +84,18 @@ class RestTest(PatchewTestCase):
self.assertEquals(resp.data['mailing_list'], "qemu-block@nongnu.org")
self.assertEquals(resp.data['parent_project'], self.PROJECT_BASE)
+ def test_project_post_no_login(self):
+ data = {
+ 'name': 'keycodemapdb',
+ }
+ resp = self.api_client.post(self.REST_BASE + 'projects/', data=data)
+ self.assertEquals(resp.status_code, 403)
+
def test_project_post_minimal(self):
data = {
'name': 'keycodemapdb',
}
+ self.api_client.login(username=self.user, password=self.password)
resp = self.api_client.post(self.REST_BASE + 'projects/', data=data)
self.assertEquals(resp.status_code, 201)
self.assertEquals(resp.data['resource_uri'].startswith(self.REST_BASE + 'projects/'), True)
@@ -97,6 +105,7 @@ class RestTest(PatchewTestCase):
self.assertEquals(resp.data['name'], data['name'])
def test_project_post(self):
+ self.api_client.login(username=self.user, password=self.password)
data = {
'name': 'keycodemapdb',
'mailing_list': 'qemu-devel@nongnu.org',
@@ -267,6 +276,7 @@ class RestTest(PatchewTestCase):
dp = self.get_data_path("0022-another-simple-patch.json.gz")
with open(dp, "r") as f:
data = f.read()
+ self.api_client.login(username=self.user, password=self.password)
resp = self.api_client.post(self.PROJECT_BASE + "messages/", data, content_type='application/json')
self.assertEqual(resp.status_code, 201)
resp_get = self.api_client.get(self.PROJECT_BASE + "messages/20171023201055.21973-11-andrew.smirnov@gmail.com/")
@@ -278,6 +288,7 @@ class RestTest(PatchewTestCase):
dp = self.get_data_path("0004-multiple-patch-reviewed.mbox.gz")
with open(dp, "r") as f:
data = f.read()
+ self.api_client.login(username=self.user, password=self.password)
resp = self.api_client.post(self.PROJECT_BASE + "messages/", data, content_type='message/rfc822')
self.assertEqual(resp.status_code, 201)
resp_get = self.api_client.get(self.PROJECT_BASE + "messages/1469192015-16487-1-git-send-email-berrange@redhat.com/")
--
2.17.0
_______________________________________________
Patchew-devel mailing list
Patchew-devel@redhat.com
https://www.redhat.com/mailman/listinfo/patchew-devel
© 2016 - 2024 Red Hat, Inc.