Make API Access a panel under compute

This patch moves the API Access view, from a tab on the Access
& Security panel to its own panel under the Compute panel group

Change-Id: I1e523ba2e7e959474c0fc77f8b6c42994a481081
Implements: blueprint reorganise-access-and-security
This commit is contained in:
Rob Cresswell 2017-01-25 11:21:24 +00:00 committed by Richard Jones
parent 94a47461c0
commit 6b2b22631b
23 changed files with 92 additions and 113 deletions

View File

@ -257,10 +257,6 @@ class FloatingIpViewTests(test.TestCase):
IsA(http.HttpRequest), IsA(http.HttpRequest),
'network').MultipleTimes() \ 'network').MultipleTimes() \
.AndReturn(True) .AndReturn(True)
api.base.is_service_enabled(
IsA(http.HttpRequest),
'ec2').MultipleTimes() \
.AndReturn(False)
self.mox.ReplayAll() self.mox.ReplayAll()
@ -314,10 +310,6 @@ class FloatingIpViewTests(test.TestCase):
IsA(http.HttpRequest), IsA(http.HttpRequest),
'network').MultipleTimes() \ 'network').MultipleTimes() \
.AndReturn(True) .AndReturn(True)
api.base.is_service_enabled(
IsA(http.HttpRequest),
'ec2').MultipleTimes() \
.AndReturn(False)
self.mox.ReplayAll() self.mox.ReplayAll()

View File

@ -24,12 +24,9 @@ from horizon import tabs
from neutronclient.common import exceptions as neutron_exc from neutronclient.common import exceptions as neutron_exc
from openstack_dashboard.api import keystone
from openstack_dashboard.api import network from openstack_dashboard.api import network
from openstack_dashboard.api import nova from openstack_dashboard.api import nova
from openstack_dashboard.dashboards.project.access_and_security.\
api_access.tables import EndpointsTable
from openstack_dashboard.dashboards.project.access_and_security.\ from openstack_dashboard.dashboards.project.access_and_security.\
floating_ips.tables import FloatingIPsTable floating_ips.tables import FloatingIPsTable
from openstack_dashboard.dashboards.project.access_and_security.\ from openstack_dashboard.dashboards.project.access_and_security.\
@ -109,23 +106,7 @@ class FloatingIPsTab(tabs.TableTab):
return network.floating_ip_supported(request) return network.floating_ip_supported(request)
class APIAccessTab(tabs.TableTab):
table_classes = (EndpointsTable,)
name = _("API Access")
slug = "api_access_tab"
template_name = "horizon/common/_detail_table.html"
def get_endpoints_data(self):
services = []
for i, service in enumerate(self.request.user.service_catalog):
service['id'] = i
services.append(
keystone.Service(service, self.request.user.services_region))
return services
class AccessAndSecurityTabs(tabs.TabGroup): class AccessAndSecurityTabs(tabs.TabGroup):
slug = "access_security_tabs" slug = "access_security_tabs"
tabs = (SecurityGroupsTab, FloatingIPsTab, APIAccessTab) tabs = (SecurityGroupsTab, FloatingIPsTab)
sticky = True sticky = True

View File

@ -1,7 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "User Credentials" %}{% endblock %}
{% block main %}
{% include 'project/access_and_security/api_access/_credentials.html' %}
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Recreate EC2 Credentials" %}{% endblock %}
{% block main %}
{% include 'project/access_and_security/api_access/_recreate_credentials.html' %}
{% endblock %}

View File

@ -25,8 +25,6 @@ import six
from horizon.workflows import views from horizon.workflows import views
from openstack_dashboard import api from openstack_dashboard import api
from openstack_dashboard.dashboards.project.access_and_security \
import api_access
from openstack_dashboard.test import helpers as test from openstack_dashboard.test import helpers as test
from openstack_dashboard.usage import quotas from openstack_dashboard.usage import quotas
@ -43,9 +41,8 @@ class AccessAndSecurityTests(test.TestCase):
'security_group_list',), 'security_group_list',),
api.nova: ('server_list',), api.nova: ('server_list',),
api.base: ('is_service_enabled',), api.base: ('is_service_enabled',),
quotas: ('tenant_quota_usages',), quotas: ('tenant_quota_usages',)})
api.keystone: ('list_ec2_credentials',)}) def _test_index(self, instanceless_ips=False):
def _test_index(self, ec2_enabled=True, instanceless_ips=False):
sec_groups = self.security_groups.list() sec_groups = self.security_groups.list()
floating_ips = self.floating_ips.list() floating_ips = self.floating_ips.list()
floating_pools = self.pools.list() floating_pools = self.pools.list()
@ -71,12 +68,6 @@ class AccessAndSecurityTests(test.TestCase):
api.base.is_service_enabled(IsA(http.HttpRequest), 'network') \ api.base.is_service_enabled(IsA(http.HttpRequest), 'network') \
.MultipleTimes().AndReturn(True) .MultipleTimes().AndReturn(True)
api.base.is_service_enabled(IsA(http.HttpRequest), 'ec2') \
.MultipleTimes().AndReturn(ec2_enabled)
if ec2_enabled:
api.keystone.list_ec2_credentials(IsA(http.HttpRequest),
self.user.id)\
.AndReturn(self.ec2.list())
self.mox.ReplayAll() self.mox.ReplayAll()
@ -99,22 +90,8 @@ class AccessAndSecurityTests(test.TestCase):
all([sec_groups_from_ctx[i].name <= sec_groups_from_ctx[i + 1].name all([sec_groups_from_ctx[i].name <= sec_groups_from_ctx[i + 1].name
for i in range(len(sec_groups_from_ctx) - 1)])) for i in range(len(sec_groups_from_ctx) - 1)]))
if ec2_enabled:
self.assertTrue(any(map(
lambda x: isinstance(x, api_access.tables.DownloadEC2),
res.context['endpoints_table'].get_table_actions()
)))
else:
self.assertFalse(any(map(
lambda x: isinstance(x, api_access.tables.DownloadEC2),
res.context['endpoints_table'].get_table_actions()
)))
def test_index(self): def test_index(self):
self._test_index(ec2_enabled=True) self._test_index()
def test_index_with_ec2_disabled(self):
self._test_index(ec2_enabled=False)
def test_index_with_instanceless_fips(self): def test_index_with_instanceless_fips(self):
self._test_index(instanceless_ips=True) self._test_index(instanceless_ips=True)
@ -198,9 +175,6 @@ class SecurityGroupTabTests(test.TestCase):
api.base.is_service_enabled( api.base.is_service_enabled(
IsA(http.HttpRequest), 'network').MultipleTimes() \ IsA(http.HttpRequest), 'network').MultipleTimes() \
.AndReturn(True) .AndReturn(True)
api.base.is_service_enabled(
IsA(http.HttpRequest), 'ec2').MultipleTimes() \
.AndReturn(False)
self.mox.ReplayAll() self.mox.ReplayAll()
@ -258,9 +232,6 @@ class SecurityGroupTabTests(test.TestCase):
api.base.is_service_enabled( api.base.is_service_enabled(
IsA(http.HttpRequest), 'network').MultipleTimes() \ IsA(http.HttpRequest), 'network').MultipleTimes() \
.AndReturn(network_enabled) .AndReturn(network_enabled)
api.base.is_service_enabled(
IsA(http.HttpRequest), 'ec2').MultipleTimes() \
.AndReturn(False)
self.mox.ReplayAll() self.mox.ReplayAll()

View File

@ -19,8 +19,6 @@
from django.conf.urls import include from django.conf.urls import include
from django.conf.urls import url from django.conf.urls import url
from openstack_dashboard.dashboards.project.access_and_security.\
api_access import urls as api_access_urls
from openstack_dashboard.dashboards.project.access_and_security.\ from openstack_dashboard.dashboards.project.access_and_security.\
floating_ips import urls as fip_urls floating_ips import urls as fip_urls
from openstack_dashboard.dashboards.project.access_and_security.\ from openstack_dashboard.dashboards.project.access_and_security.\
@ -30,7 +28,6 @@ from openstack_dashboard.dashboards.project.access_and_security import views
urlpatterns = [ urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'), url(r'^$', views.IndexView.as_view(), name='index'),
url(r'api_access/', include(api_access_urls, namespace='api_access')),
url(r'floating_ips/', include(fip_urls, namespace='floating_ips')), url(r'floating_ips/', include(fip_urls, namespace='floating_ips')),
url(r'security_groups/', url(r'security_groups/',
include(sec_group_urls, namespace='security_groups')), include(sec_group_urls, namespace='security_groups')),

View File

@ -0,0 +1,21 @@
# Copyright 2017 Cisco Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.utils.translation import ugettext_lazy as _
import horizon
class ApiAccess(horizon.Panel):
name = _("API Access")
slug = 'api_access'

View File

@ -19,8 +19,7 @@ from openstack_auth import utils
from horizon import tables from horizon import tables
from openstack_dashboard import api from openstack_dashboard import api
from openstack_dashboard.dashboards.project.access_and_security.api_access \ from openstack_dashboard.dashboards.project.api_access import forms
import forms as project_forms
from openstack_dashboard import policy from openstack_dashboard import policy
@ -38,7 +37,7 @@ class DownloadEC2(tables.LinkAction):
verbose_name = _("Download EC2 Credentials") verbose_name = _("Download EC2 Credentials")
verbose_name_plural = _("Download EC2 Credentials") verbose_name_plural = _("Download EC2 Credentials")
icon = "download" icon = "download"
url = "horizon:project:access_and_security:api_access:ec2" url = "horizon:project:api_access:ec2"
policy_rules = (("compute", "compute_extension:certificates"),) policy_rules = (("compute", "compute_extension:certificates"),)
def allowed(self, request, datum=None): def allowed(self, request, datum=None):
@ -50,7 +49,7 @@ class DownloadOpenRC(tables.LinkAction):
verbose_name = _("Download OpenStack RC File v3") verbose_name = _("Download OpenStack RC File v3")
verbose_name_plural = _("Download OpenStack RC File v3") verbose_name_plural = _("Download OpenStack RC File v3")
icon = "download" icon = "download"
url = "horizon:project:access_and_security:api_access:openrc" url = "horizon:project:api_access:openrc"
def allowed(self, request, datum=None): def allowed(self, request, datum=None):
return utils.get_keystone_version() >= 3 return utils.get_keystone_version() >= 3
@ -61,7 +60,7 @@ class DownloadOpenRCv2(tables.LinkAction):
verbose_name = _("Download OpenStack RC File v2.0") verbose_name = _("Download OpenStack RC File v2.0")
verbose_name_plural = _("Download OpenStack RC File v2.0") verbose_name_plural = _("Download OpenStack RC File v2.0")
icon = "download" icon = "download"
url = "horizon:project:access_and_security:api_access:openrcv2" url = "horizon:project:api_access:openrcv2"
class ViewCredentials(tables.LinkAction): class ViewCredentials(tables.LinkAction):
@ -69,7 +68,7 @@ class ViewCredentials(tables.LinkAction):
verbose_name = _("View Credentials") verbose_name = _("View Credentials")
classes = ("ajax-modal", ) classes = ("ajax-modal", )
icon = "eye" icon = "eye"
url = "horizon:project:access_and_security:api_access:view_credentials" url = "horizon:project:api_access:view_credentials"
class RecreateCredentials(tables.LinkAction): class RecreateCredentials(tables.LinkAction):
@ -78,7 +77,7 @@ class RecreateCredentials(tables.LinkAction):
classes = ("ajax-modal",) classes = ("ajax-modal",)
icon = "refresh" icon = "refresh"
url = \ url = \
"horizon:project:access_and_security:api_access:recreate_credentials" "horizon:project:api_access:recreate_credentials"
policy_rules = (("compute", "compute_extension:certificates")) policy_rules = (("compute", "compute_extension:certificates"))
action_type = "danger" action_type = "danger"
@ -86,7 +85,7 @@ class RecreateCredentials(tables.LinkAction):
try: try:
target = {"target.credential.user_id": request.user.id} target = {"target.credential.user_id": request.user.id}
if (api.base.is_service_enabled(request, 'ec2') and if (api.base.is_service_enabled(request, 'ec2') and
project_forms.get_ec2_credentials(request) and forms.get_ec2_credentials(request) and
policy.check((("identity", "identity:ec2_create_credential"), policy.check((("identity", "identity:ec2_create_credential"),
("identity", "identity:ec2_delete_credential")), ("identity", "identity:ec2_delete_credential")),
request, target=target)): request, target=target)):

View File

@ -65,5 +65,5 @@
{% endblock %} {% endblock %}
{% block modal-footer %} {% block modal-footer %}
<a href="{% url 'horizon:project:access_and_security:index' %}" class="btn btn-default cancel">{% trans "Close" %}</a> <a href="{% url 'horizon:project:api_access:index' %}" class="btn btn-default cancel">{% trans "Close" %}</a>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block main %}
{% include 'project/api_access/_credentials.html' %}
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block main %}
{% include 'project/api_access/_recreate_credentials.html' %}
{% endblock %}

View File

@ -24,8 +24,8 @@ from openstack_dashboard import api
from openstack_dashboard.test import helpers as test from openstack_dashboard.test import helpers as test
INDEX_URL = reverse('horizon:project:access_and_security:index') INDEX_URL = reverse('horizon:project:api_access:index')
API_URL = "horizon:project:access_and_security:api_access" API_URL = "horizon:project:api_access"
EC2_URL = reverse(API_URL + ":ec2") EC2_URL = reverse(API_URL + ":ec2")
OPENRC_URL = reverse(API_URL + ":openrc") OPENRC_URL = reverse(API_URL + ":openrc")
OPENRCV2_URL = reverse(API_URL + ":openrcv2") OPENRCV2_URL = reverse(API_URL + ":openrcv2")
@ -60,7 +60,7 @@ class APIAccessTests(test.TestCase):
def test_openrcv2_credentials(self): def test_openrcv2_credentials(self):
res = self.client.get(OPENRCV2_URL) res = self.client.get(OPENRCV2_URL)
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
openrc = 'project/access_and_security/api_access/openrc_v2.sh.template' openrc = 'project/api_access/openrc_v2.sh.template'
self.assertTemplateUsed(res, openrc) self.assertTemplateUsed(res, openrc)
name = 'export OS_USERNAME="{}"'.format(self.request.user.username) name = 'export OS_USERNAME="{}"'.format(self.request.user.username)
t_id = 'export OS_TENANT_ID={}'.format(self.request.user.tenant_id) t_id = 'export OS_TENANT_ID={}'.format(self.request.user.tenant_id)
@ -75,7 +75,7 @@ class APIAccessTests(test.TestCase):
def test_openrc_credentials(self): def test_openrc_credentials(self):
res = self.client.get(OPENRC_URL) res = self.client.get(OPENRC_URL)
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
openrc = 'project/access_and_security/api_access/openrc.sh.template' openrc = 'project/api_access/openrc.sh.template'
self.assertTemplateUsed(res, openrc) self.assertTemplateUsed(res, openrc)
name = 'export OS_USERNAME="{}"'.format(self.request.user.username) name = 'export OS_USERNAME="{}"'.format(self.request.user.username)
p_id = 'export OS_PROJECT_ID={}'.format(self.request.user.tenant_id) p_id = 'export OS_PROJECT_ID={}'.format(self.request.user.tenant_id)
@ -95,7 +95,7 @@ class APIAccessTests(test.TestCase):
res = self.client.get(CREDS_URL) res = self.client.get(CREDS_URL)
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
credentials = 'project/access_and_security/api_access/credentials.html' credentials = 'project/api_access/credentials.html'
self.assertTemplateUsed(res, credentials) self.assertTemplateUsed(res, credentials)
self.assertEqual(self.user.id, res.context['openrc_creds']['user'].id) self.assertEqual(self.user.id, res.context['openrc_creds']['user'].id)
self.assertEqual(certs[0].access, self.assertEqual(certs[0].access,
@ -125,7 +125,7 @@ class APIAccessTests(test.TestCase):
res_get = self.client.get(RECREATE_CREDS_URL) res_get = self.client.get(RECREATE_CREDS_URL)
self.assertEqual(res_get.status_code, 200) self.assertEqual(res_get.status_code, 200)
credentials = \ credentials = \
'project/access_and_security/api_access/recreate_credentials.html' 'project/api_access/recreate_credentials.html'
self.assertTemplateUsed(res_get, credentials) self.assertTemplateUsed(res_get, credentials)
res_post = self.client.post(RECREATE_CREDS_URL) res_post = self.client.post(RECREATE_CREDS_URL)

View File

@ -17,12 +17,10 @@
# under the License. # under the License.
from django.conf.urls import url from django.conf.urls import url
from openstack_dashboard.dashboards.project.api_access import views
from openstack_dashboard.dashboards.project.access_and_security.\
api_access import views
urlpatterns = [ urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^ec2/$', views.download_ec2_bundle, name='ec2'), url(r'^ec2/$', views.download_ec2_bundle, name='ec2'),
url(r'^openrc/$', views.download_rc_file, name='openrc'), url(r'^openrc/$', views.download_rc_file, name='openrc'),
url(r'^openrcv2/$', views.download_rc_file_v2, name='openrcv2'), url(r'^openrcv2/$', views.download_rc_file_v2, name='openrcv2'),

View File

@ -28,11 +28,14 @@ from openstack_auth import utils
from horizon import exceptions from horizon import exceptions
from horizon import forms from horizon import forms
from horizon import messages from horizon import messages
from horizon import tables
from horizon import views from horizon import views
from openstack_dashboard import api from openstack_dashboard import api
from openstack_dashboard.dashboards.project.access_and_security.api_access \ from openstack_dashboard.dashboards.project.api_access \
import forms as project_forms import forms as api_access_forms
from openstack_dashboard.dashboards.project.api_access \
import tables as api_access_tables
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -95,7 +98,7 @@ def download_ec2_bundle(request):
redirect=request.build_absolute_uri()) redirect=request.build_absolute_uri())
# Create our file bundle # Create our file bundle
template = 'project/access_and_security/api_access/ec2rc.sh.template' template = 'project/api_access/ec2rc.sh.template'
try: try:
temp_zip = tempfile.NamedTemporaryFile(delete=True) temp_zip = tempfile.NamedTemporaryFile(delete=True)
with closing(zipfile.ZipFile(temp_zip.name, mode='w')) as archive: with closing(zipfile.ZipFile(temp_zip.name, mode='w')) as archive:
@ -119,7 +122,7 @@ def download_ec2_bundle(request):
def download_rc_file_v2(request): def download_rc_file_v2(request):
template = 'project/access_and_security/api_access/openrc_v2.sh.template' template = 'project/api_access/openrc_v2.sh.template'
context = _get_openrc_credentials(request) context = _get_openrc_credentials(request)
context['os_identity_api_version'] = 2 context['os_identity_api_version'] = 2
context['os_auth_version'] = 2 context['os_auth_version'] = 2
@ -127,7 +130,7 @@ def download_rc_file_v2(request):
def download_rc_file(request): def download_rc_file(request):
template = 'project/access_and_security/api_access/openrc.sh.template' template = 'project/api_access/openrc.sh.template'
context = _get_openrc_credentials(request) context = _get_openrc_credentials(request)
# make v3 specific changes # make v3 specific changes
@ -159,7 +162,7 @@ def _download_rc_file_for_template(request, context, template):
class CredentialsView(forms.ModalFormMixin, views.HorizonTemplateView): class CredentialsView(forms.ModalFormMixin, views.HorizonTemplateView):
template_name = 'project/access_and_security/api_access/credentials.html' template_name = 'project/api_access/credentials.html'
page_title = _("User Credentials Details") page_title = _("User Credentials Details")
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -179,12 +182,27 @@ class CredentialsView(forms.ModalFormMixin, views.HorizonTemplateView):
class RecreateCredentialsView(forms.ModalFormView): class RecreateCredentialsView(forms.ModalFormView):
form_class = project_forms.RecreateCredentials form_class = api_access_forms.RecreateCredentials
form_id = "recreate_credentials" form_id = "recreate_credentials"
page_title = _("Recreate EC2 Credentials") page_title = _("Recreate EC2 Credentials")
template_name = \ template_name = \
'project/access_and_security/api_access/recreate_credentials.html' 'project/api_access/recreate_credentials.html'
submit_label = _("Recreate EC2 Credentials") submit_label = _("Recreate EC2 Credentials")
submit_url = reverse_lazy( submit_url = reverse_lazy(
"horizon:project:access_and_security:api_access:recreate_credentials") "horizon:project:api_access:recreate_credentials")
success_url = reverse_lazy('horizon:project:access_and_security:index') success_url = reverse_lazy('horizon:project:api_access:index')
class IndexView(tables.DataTableView):
table_class = api_access_tables.EndpointsTable
page_title = _("API Access")
def get_data(self):
services = []
for i, service in enumerate(self.request.user.service_catalog):
service['id'] = i
services.append(
api.keystone.Service(service,
self.request.user.services_region))
return services

View File

@ -0,0 +1,6 @@
PANEL_DASHBOARD = 'project'
PANEL_GROUP = 'compute'
PANEL = 'api_access'
ADD_PANEL = \
('openstack_dashboard.dashboards.project.api_access.panel.ApiAccess')

View File

@ -33,7 +33,7 @@ class TemplateRenderTest(test.TestCase):
"auth_url": "http://tests.com", "auth_url": "http://tests.com",
"tenant_name": "ENG Perf R&D"} "tenant_name": "ENG Perf R&D"}
out = loader.render_to_string( out = loader.render_to_string(
'project/access_and_security/api_access/openrc.sh.template', 'project/api_access/openrc.sh.template',
context, context,
template.Context(context)) template.Context(context))
@ -47,7 +47,7 @@ class TemplateRenderTest(test.TestCase):
"auth_url": "http://tests.com", "auth_url": "http://tests.com",
"tenant_name": 'o"; sudo rm -rf /'} "tenant_name": 'o"; sudo rm -rf /'}
out = loader.render_to_string( out = loader.render_to_string(
'project/access_and_security/api_access/openrc.sh.template', 'project/api_access/openrc.sh.template',
context, context,
template.Context(context)) template.Context(context))
@ -61,7 +61,7 @@ class TemplateRenderTest(test.TestCase):
"auth_url": "http://tests.com", "auth_url": "http://tests.com",
"tenant_name": 'o\"; sudo rm -rf /'} "tenant_name": 'o\"; sudo rm -rf /'}
out = loader.render_to_string( out = loader.render_to_string(
'project/access_and_security/api_access/openrc.sh.template', 'project/api_access/openrc.sh.template',
context, context,
template.Context(context)) template.Context(context))
@ -77,7 +77,7 @@ class TemplateRenderTest(test.TestCase):
"tenant_name": "Tenant", "tenant_name": "Tenant",
"region": "Colorado"} "region": "Colorado"}
out = loader.render_to_string( out = loader.render_to_string(
'project/access_and_security/api_access/openrc.sh.template', 'project/api_access/openrc.sh.template',
context, context,
template.Context(context)) template.Context(context))
@ -90,7 +90,7 @@ class TemplateRenderTest(test.TestCase):
"auth_url": "http://tests.com", "auth_url": "http://tests.com",
"tenant_name": "Tenant"} "tenant_name": "Tenant"}
out = loader.render_to_string( out = loader.render_to_string(
'project/access_and_security/api_access/openrc.sh.template', 'project/api_access/openrc.sh.template',
context, context,
template.Context(context)) template.Context(context))

View File

@ -1,5 +1,5 @@
--- ---
features: features:
- The Access & Security panel's tabs have been moved to their own panels for - The Access & Security panel's tabs have been moved to their own panels for
clearer navigation and better performance. Key Pairs now resides in the clearer navigation and better performance. API Access and Key Pairs now
Compute panel group. reside in the Compute panel group.