Add endpoint for switching project by name

Add a switch_name URL that can be used to switch
project by name by migrating the switch (project ID)
function based view to a class based view and then
reuse the implementation for another view for the
switch_name functionality.

Change-Id: I2e8aecd9bf3151fb42a326d5b9aa28b98a2472d5
Signed-off-by: Tobias Urdin <tobias.urdin@binero.com>
This commit is contained in:
Tobias Urdin
2026-06-12 23:43:57 +02:00
parent c17623de60
commit ff192c21aa
7 changed files with 173 additions and 59 deletions
+66 -1
View File
@@ -40,6 +40,7 @@ DEFAULT_DOMAIN = settings.OPENSTACK_KEYSTONE_DEFAULT_DOMAIN
# figure out how to avoid this.
class IsA(object):
"""Class to compare param is a specified class."""
def __init__(self, cls):
self.cls = cls
@@ -1309,7 +1310,7 @@ class OpenStackAuthTests(test.TestCase):
response = self.client.post(url, form_data)
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL)
url = reverse('switch_tenants', args=[project.id])
url = reverse('switch_project_id', args=[project.id])
scoped._project['id'] = self.data.project_two.id
@@ -1339,6 +1340,70 @@ class OpenStackAuthTests(test.TestCase):
def test_switch_with_wrong_next(self):
self.test_switch(next='/bad_url')
@mock.patch.object(v3_auth.Token, 'get_access')
@mock.patch.object(password.PasswordPlugin, 'list_projects')
@mock.patch.object(v3_auth.Password, 'get_access')
def test_switch_by_name(self, mock_get_access, mock_project_list,
mock_get_access_token,
next=None):
def mock_redirect_return(param):
if 'bad' not in param:
return original_redirect(param)
else:
raise NoReverseMatch
project = self.data.project_two
projects = [self.data.project_one, self.data.project_two]
user = self.data.user
scoped = self.data.scoped_access_info
form_data = self.get_form_data(user)
mock_get_access.return_value = self.data.unscoped_access_info
mock_get_access_token.return_value = scoped
mock_project_list.return_value = projects
original_redirect = shortcuts.redirect
with mock.patch('django.shortcuts.redirect',
side_effect=mock_redirect_return):
url = reverse('login')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response = self.client.post(url, form_data)
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL)
url = reverse('switch_project_name', args=[project.name])
scoped._project['id'] = self.data.project_two.id
if next:
form_data.update({auth.REDIRECT_FIELD_NAME: next})
response = self.client.get(url, form_data)
if next and 'bad' not in next:
expected_url = next
self.assertEqual(response['location'], expected_url)
else:
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL)
self.assertEqual(self.client.session['token'].project['id'],
scoped.project_id)
mock_get_access.assert_called_once_with(IsA(session.Session))
mock_get_access_token.assert_called_with(IsA(session.Session))
mock_project_list.assert_called_once_with(
IsA(session.Session),
IsA(v3_auth.Password),
self.data.unscoped_access_info)
def test_switch_by_name_with_next(self):
self.test_switch_by_name(next='/next_url')
def test_switch_by_name_with_wrong_next(self):
self.test_switch_by_name(next='/bad_url')
@mock.patch.object(v3_auth.Token, 'get_access')
@mock.patch.object(password.PasswordPlugin, 'list_projects')
@mock.patch.object(v3_auth.Password, 'get_access')
+6 -2
View File
@@ -22,8 +22,12 @@ from openstack_auth import views
urlpatterns = [
re_path(r"^login/$", views.login, name='login'),
re_path(r"^logout/$", views.logout, name='logout'),
re_path(r'^switch/(?P<tenant_id>[^/]+)/$', views.switch,
name='switch_tenants'),
re_path(r'^switch/(?P<project>[^/]+)/$',
views.ProjectSwitchByIDView.as_view(),
name='switch_project_id'),
re_path(r'^switch_name/(?P<project>[^/]+)/$',
views.ProjectSwitchByNameView.as_view(),
name='switch_project_name'),
re_path(r'^switch_services_region/(?P<region_name>[^/]+)/$',
views.switch_region,
name='switch_services_region'),
+8 -1
View File
@@ -296,7 +296,8 @@ def clean_up_auth_url(auth_url):
def get_token_auth_plugin(auth_url, token, project_id=None, domain_name=None,
system_scope=None):
system_scope=None, project_name=None,
project_domain_id=None):
if system_scope:
return v3_auth.Token(auth_url=auth_url,
token=token,
@@ -307,6 +308,12 @@ def get_token_auth_plugin(auth_url, token, project_id=None, domain_name=None,
token=token,
domain_name=domain_name,
reauthenticate=False)
if project_name and project_domain_id:
return v3_auth.Token(auth_url=auth_url,
token=token,
project_name=project_name,
project_domain_id=project_domain_id,
reauthenticate=False)
return v3_auth.Token(auth_url=auth_url,
token=token,
project_id=project_id,
+80 -52
View File
@@ -24,6 +24,7 @@ from django.middleware import csrf
from django import shortcuts
from django.urls import NoReverseMatch
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils import http
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
@@ -32,6 +33,7 @@ from django.views.decorators.csrf import csrf_protect
from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_POST
from django.views.generic import edit as edit_views
from django.views import View
from keystoneauth1 import exceptions as keystone_exceptions
from openstack_auth import exceptions
@@ -301,62 +303,88 @@ def logout(request, login_url=None, **kwargs):
)
# TODO(stephenfin): Migrate to CBV
@login_required
def switch(request, tenant_id, redirect_field_name=auth.REDIRECT_FIELD_NAME):
"""Switches an authenticated user from one project to another."""
LOG.debug('Switching to tenant %s for user "%s".',
tenant_id, request.user.username)
class ProjectSwitchView(View):
def _get_token_auth_plugin_kwargs(self, request, project, domain=None):
"""This need to be implemented by the view."""
return {}
endpoint, __ = utils.fix_auth_url_version_prefix(request.user.endpoint)
client_ip = utils.get_client_ip(request)
session = utils.get_session(original_ip=client_ip)
# Keystone can be configured to prevent exchanging a scoped token for
# another token. Always use the unscoped token for requesting a
# scoped token.
unscoped_token = request.user.unscoped_token
auth = utils.get_token_auth_plugin(auth_url=endpoint,
token=unscoped_token,
project_id=tenant_id)
def get(self, request, project):
LOG.debug('Switching to project %s for user "%s".',
project, request.user.username)
endpoint, __ = utils.fix_auth_url_version_prefix(request.user.endpoint)
client_ip = utils.get_client_ip(request)
session = utils.get_session(original_ip=client_ip)
# Keystone can be configured to prevent exchanging a scoped token for
# another token. Always use the unscoped token for requesting a
# scoped token.
unscoped_token = request.user.unscoped_token
try:
auth_ref = auth.get_access(session)
msg = 'Project switch successful for user "%(username)s".' % \
{'username': request.user.username}
LOG.info(msg)
except keystone_exceptions.ClientException:
msg = (
_('Project switch failed for user "%(username)s".') %
{'username': request.user.username})
messages.error(request, msg)
auth_ref = None
LOG.exception('An error occurred while switching sessions.')
auth_plugin_kwargs = {
'auth_url': endpoint,
'token': unscoped_token,
}
requested_domain_id = request.GET.get('domain_id')
auth_plugin_kwargs.update(
self._get_token_auth_plugin_kwargs(
request, project, domain=requested_domain_id))
auth_plugin = utils.get_token_auth_plugin(**auth_plugin_kwargs)
# Ensure the user-originating redirection url is safe.
# Taken from django.contrib.auth.views.login()
redirect_to = request.GET.get(redirect_field_name, '')
if (not http.url_has_allowed_host_and_scheme(
url=redirect_to,
allowed_hosts=[request.get_host()])):
redirect_to = settings.LOGIN_REDIRECT_URL
try:
auth_ref = auth_plugin.get_access(session)
msg = 'Project switch successful for user "%(username)s".' % \
{'username': request.user.username}
LOG.info(msg)
except keystone_exceptions.ClientException:
msg = (
_('Project switch failed for user "%(username)s".') %
{'username': request.user.username})
messages.error(request, msg)
auth_ref = None
LOG.exception('An error occurred while switching sessions.')
if auth_ref:
user = auth_user.create_user_from_token(
request,
auth_user.Token(auth_ref, unscoped_token=unscoped_token),
endpoint)
auth_user.set_session_from_user(request, user)
message = (
_('Switch to project "%(project_name)s" successful.') %
{'project_name': request.user.project_name})
messages.success(request, message)
try:
response = shortcuts.redirect(redirect_to)
except NoReverseMatch:
response = django_http.HttpResponseRedirect(settings.LOGIN_REDIRECT_URL)
utils.set_response_cookie(response, 'recent_project',
request.user.project_id)
return response
# Ensure the user-originating redirection url is safe.
# Taken from django.contrib.auth.views.login()
redirect_to = request.GET.get(auth.REDIRECT_FIELD_NAME, '')
if (not http.url_has_allowed_host_and_scheme(
url=redirect_to,
allowed_hosts=[request.get_host()])):
redirect_to = settings.LOGIN_REDIRECT_URL
if auth_ref:
user = auth_user.create_user_from_token(
request,
auth_user.Token(auth_ref, unscoped_token=unscoped_token),
endpoint)
auth_user.set_session_from_user(request, user)
message = (
_('Switch to project "%(project_name)s" successful.') %
{'project_name': request.user.project_name})
messages.success(request, message)
try:
response = shortcuts.redirect(redirect_to)
except NoReverseMatch:
response = django_http.HttpResponseRedirect(
settings.LOGIN_REDIRECT_URL)
utils.set_response_cookie(response, 'recent_project',
request.user.project_id)
return response
@method_decorator(login_required, name="dispatch")
class ProjectSwitchByIDView(ProjectSwitchView):
def _get_token_auth_plugin_kwargs(self, request, project, domain=None):
return {'project_id': project}
@method_decorator(login_required, name="dispatch")
class ProjectSwitchByNameView(ProjectSwitchView):
def _get_token_auth_plugin_kwargs(self, request, project, domain=None):
current_domain = request.user.token.project['domain_id']
return {
'project_name': project,
'project_domain_id': domain or current_domain,
}
# TODO(stephenfin): Migrate to CBV
@@ -28,7 +28,7 @@ from openstack_dashboard.usage import quotas
class RescopeTokenToProject(tables.LinkAction):
name = "rescope"
verbose_name = _("Set as Active Project")
url = "switch_tenants"
url = "switch_project_id"
def allowed(self, request, project):
# allow rescoping token to any project the user has a role on,
@@ -39,7 +39,7 @@ class RescopeTokenToProject(tables.LinkAction):
project.enabled), False)
def get_link_url(self, project):
# redirects to the switch_tenants url which then will redirect
# redirects to the switch_project_id url which then will redirect
# back to this page
dash_url = reverse("horizon:identity:projects:index")
base_url = reverse(self.url, args=[project.id])
@@ -5,7 +5,7 @@
{% for project in projects %}
<li>
<a class="{% if project.enabled and project.id == project_id %} dropdown-selected{% endif %}"
href="{% url 'switch_tenants' project.id %}{% if page_url %}?next={{ page_url }}{% endif %}"
href="{% url 'switch_project_id' project.id %}{% if page_url %}?next={{ page_url }}{% endif %}"
target="_self">
<span class="fa fa-check dropdown-selected-icon"></span>
<span class="dropdown-title">
@@ -0,0 +1,10 @@
---
features:
- |
Added a new URL endpoint ``/auth/switch_name/<project-name>`` to Horizon that can
be used to switch to another project by name, if ``domain_id`` query parameter
is set that domain ID is used otherwise the domain for the current project scoped
token is used.
This is the same functionality as is already provided by the ``/auth/switch/<project-id>``
endpoint that only works with an project ID (UUID).