Add OIDC authentication support to DC CLI

This commit introduces OIDC authentication to the DC CLI
while preserving Keystone as the default authentication method.

Behavior & configuration:
- New authentication selector available as:
  * Environment variable: STX_AUTH_TYPE={keystone|oidc}
  * CLI parameter: --stx-auth-type keystone|oidc
  - Default: keystone (existing behavior)

- Keystone flow (default or when STX_AUTH_TYPE/--stx-auth-type=keystone):
  - Unchanged; authenticate with Keystone using X-Auth-Token.

- OIDC flow (when STX_AUTH_TYPE/--stx-auth-type=oidc):
  - Obtain OIDC ID/Refresh tokens from $KUBECONFIG (or ~/.kube/config
    if not specified); no local token discovery beyond kubeconfig.
  - Send OIDC ID-Token via custom HTTPS header: OIDC-Token.
  - Build REST API URL without Keystone catalog:
    * Protocol from OS_INTERFACE: public/admin=https, internal=http.
    * IP/host from OS_AUTH_URL (MGMT locally, OAM remotely).
    * Hard-code port and API path per interface (v1/v3 variants),
      including region dimension (RegionOne vs SystemController).

Test Plan:
PASS: CLI authenticates with Keystone by default or with
      STX_AUTH_TYPE/--stx-auth-type=keystone.
PASS: CLI authenticates with OIDC when
      STX_AUTH_TYPE/--stx-auth-type=oidc is specified.
PASS: CLI reads OIDC ID/Refresh tokens from kubeconfig.
PASS: CLI sends OIDC-Token header in OIDC mode.
PASS: In an https enabled system, run a dcmanager command with
      --os-endpoint-type parameter set to public and the --os-auth-url
      pointing to the oam ip and verify the request succeeds

Depends-On: https://review.opendev.org/c/starlingx/distcloud/+/966303

Story: 2011646
Task: 53595

Change-Id: Iff55f653258bdf40247baf3490943f884d41d781
Signed-off-by: Hugo Brito <hugo.brito@windriver.com>
This commit is contained in:
Hugo Brito
2025-11-06 09:46:46 -03:00
committed by Raphael
parent d062c55776
commit 62fe1ee076
9 changed files with 262 additions and 37 deletions
+1
View File
@@ -30,6 +30,7 @@ nosetests.xml
.stestr
.testrepository
.venv
test.yaml
# Translations
*.mo
+8
View File
@@ -27,6 +27,8 @@
parent: tox-py39
description: Run py39 for distcloud-client
nodeset: debian-bullseye
required-projects:
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: py39
@@ -37,6 +39,8 @@
parent: tox
description: Run pylint for distcloud-client
nodeset: debian-bullseye
required-projects:
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: pylint
@@ -47,6 +51,8 @@
parent: tox
description: Run pep8 for distcloud-client
nodeset: debian-bullseye
required-projects:
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: pep8
@@ -57,6 +63,8 @@
parent: tox
description: Run black for distcloud-client
nodeset: debian-bullseye
required-projects:
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: black_check
+1 -1
View File
@@ -92,7 +92,7 @@ ignore-mixin-members=yes
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis
ignored-modules=distutils,eventlet.green.subprocess
ignored-modules=distutils,platform_util,eventlet.green.subprocess
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
@@ -1,7 +1,7 @@
# Copyright 2013 - Mirantis, Inc.
# Copyright 2016 - StackStorm, Inc.
# Copyright 2016 - Ericsson AB.
# Copyright (c) 2017-2021, 2024 Wind River Systems, Inc.
# Copyright (c) 2017-2021, 2024, 2026 Wind River Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -51,11 +51,13 @@ class HTTPClient:
user_id=None,
cacert=None,
insecure=False,
auth_type="keystone",
):
self.base_url = base_url
self.token = token
self.project_id = project_id
self.user_id = user_id
self.auth_type = auth_type
self.ssl_options = {}
if self.base_url.startswith("https"):
@@ -120,7 +122,12 @@ class HTTPClient:
token = headers.get("x-auth-token", self.token)
if token:
headers["x-auth-token"] = token
if self.auth_type == "oidc":
# For OIDC authentication, set OIDC-Token header
headers["OIDC-Token"] = token
else:
# For Keystone authentication, set X-Auth-Token header
headers["x-auth-token"] = token
project_id = headers.get("X-Project-Id", self.project_id)
if project_id:
@@ -1,7 +1,7 @@
# Copyright 2014 - Mirantis, Inc.
# Copyright 2015 - StackStorm, Inc.
# Copyright 2016 - Ericsson AB.
# Copyright (c) 2017-2025 Wind River Systems, Inc.
# Copyright (c) 2017-2026 Wind River Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -19,10 +19,12 @@
import datetime
import logging
from typing import Union
from urllib.parse import urlparse
import keystoneauth1.identity.generic as auth_plugin
import osprofiler.profiler
from keystoneauth1 import session as ks_session
import osprofiler.profiler
from platform_util.oidc import oidc_utils
from dcmanagerclient import utils
from dcmanagerclient.api import httpclient
@@ -47,6 +49,7 @@ from dcmanagerclient.api.v1.sw_prestage_manager import SwPrestageManager
from dcmanagerclient.api.v1.sw_strategy_manager import SwStrategyManager
from dcmanagerclient.api.v1.sw_update_options_manager import SwUpdateOptionsManager
from dcmanagerclient.api.v1.system_peer_manager import SystemPeerManager
from dcmanagerclient import exceptions
LOG = logging.getLogger(__name__)
_DEFAULT_DCMANAGER_URL = "http://localhost:8119/v1.0"
@@ -68,7 +71,7 @@ class Client:
api_key=None,
project_name=None,
auth_url=None,
project_id=None,
project_id="admin",
endpoint_type="publicURL",
service_type="dcmanager",
auth_token=None,
@@ -108,10 +111,23 @@ class Client:
refresh_cache=refresh_cache,
**kwargs,
)
elif auth_type == "oidc":
if not username:
raise RuntimeError("Username is required for OIDC authentication")
try:
(dcmanager_url, auth_token) = _get_oidc_data(
username,
auth_url,
endpoint_type,
service_type,
)
except Exception as e:
raise RuntimeError(f"OIDC authentication failed: {e}") from e
else:
raise RuntimeError(
"Invalid authentication type "
f"[value={auth_type}, valid_values=keystone]"
f"[value={auth_type}, valid_values=keystone,oidc]"
)
if not dcmanager_url:
@@ -127,6 +143,7 @@ class Client:
user_id,
cacert=cacert,
insecure=insecure,
auth_type=auth_type,
)
# Create all managers
@@ -182,6 +199,8 @@ def authenticate(
user_domain_id = kwargs.get("user_domain_id")
project_domain_name = kwargs.get("project_domain_name")
project_domain_id = kwargs.get("project_domain_id")
token = None
verify = False if insecure else (cacert if cacert else True)
if session is None:
if auth_token:
@@ -192,8 +211,6 @@ def authenticate(
project_name=project_name,
project_domain_name=project_domain_name,
project_domain_id=project_domain_id,
cacert=cacert,
insecure=insecure,
)
elif api_key and (username or user_id):
@@ -212,11 +229,10 @@ def authenticate(
else:
raise RuntimeError(
"You must either provide a valid token or"
"a password (api_key) and a user."
"You must either provide a valid token or "
"a password (api_key) and a username."
)
if auth:
session = ks_session.Session(auth=auth)
session = ks_session.Session(auth=auth, verify=verify)
if session:
token, cache_key = None, _cache_key(username)
@@ -251,3 +267,100 @@ def authenticate(
)
return dcmanager_url, token, project_id, user_id
def _get_oidc_data(username, auth_url, endpoint_type, service_type):
"""Get OIDC token and dcmanager URL.
Args:
username: Username for OIDC token lookup
auth_url: OS_AUTH_URL for service URL construction
endpoint_type: Interface type (publicURL, internalURL, adminURL)
service_type: Service type (dcmanager)
Returns:
tuple: (dcmanager_url, oidc_token)
"""
dcmanager_url = _build_service_url(auth_url, service_type, endpoint_type)
# Client should ALWAYS read OIDC Token from $KUBECONFIG file, no caching
oidc_token = oidc_utils.get_oidc_token(username)
if not oidc_token:
raise exceptions.DCManagerClientException(
f"No OIDC token found for user '{username}' in kubeconfig"
)
return dcmanager_url, oidc_token
def _build_service_url(auth_url, service_type, endpoint_type):
"""Build service URL from auth_url and service parameters.
Args:
auth_url: OS_AUTH_URL containing the IP address
service_type: Service type (e.g., 'dcmanager')
endpoint_type: Interface type (publicURL, internalURL, adminURL)
Returns:
str: Complete service URL
"""
if not auth_url:
raise exceptions.DCManagerClientException(
"OS_AUTH_URL is required for OIDC authentication"
)
# Parse IP address from auth_url
parsed = urlparse(auth_url)
ip_address = parsed.hostname
if not ip_address:
raise exceptions.DCManagerClientException(
f"Cannot extract IP address from OS_AUTH_URL: {auth_url}"
)
# Map endpoint types to interface names
interface_map = {
"publicURL": "public",
"internalURL": "internal",
"adminURL": "admin",
}
interface = interface_map.get(endpoint_type)
# Service port and path mappings
service_ports = {
"public": {"dcmanager": 8119},
"internal": {"dcmanager": 8119},
"admin": {"dcmanager": 8120},
}
service_paths = {
"public": {"dcmanager": "/v1.0"},
"internal": {"dcmanager": "/v1.0"},
"admin": {"dcmanager": "/v1.0"},
}
# Determine protocol
# HTTPS should always be enabled for public endpoints when using OIDC
if interface in ["public", "admin"]:
protocol = "https"
else:
protocol = "http"
# Get port and path
port = service_ports.get(interface, {}).get(service_type)
path = service_paths.get(interface, {}).get(service_type)
if not port or not path:
raise exceptions.DCManagerClientException(
f"No port/path mapping for service {service_type} on "
f"interface {interface}"
)
# Format IP address for URL (IPv6 needs brackets)
if ":" in ip_address and not ip_address.startswith("["):
formatted_ip = f"[{ip_address}]"
else:
formatted_ip = ip_address
return f"{protocol}://{formatted_ip}:{port}{path}"
@@ -1,5 +1,5 @@
# Copyright 2015 - Ericsson AB.
# Copyright (c) 2017-2025 Wind River Systems, Inc.
# Copyright (c) 2017-2026 Wind River Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -529,6 +529,15 @@ class DCManagerShell(app.App):
),
)
parser.add_argument(
"--stx-auth-type",
action="store",
dest="stx_auth_type",
default=env("STX_AUTH_TYPE", default="keystone"),
choices=["keystone", "oidc"],
help="Authentication type: keystone or oidc (Env: STX_AUTH_TYPE)",
)
return parser
def print_help_if_requested(self):
@@ -538,28 +547,35 @@ class DCManagerShell(app.App):
def load_client(self, refresh_cache, skip_auth=True):
if self.options.auth_url and not self.options.token and not skip_auth:
if not self.options.tenant_name:
raise exceptions.CommandError(
(
"You must provide a tenant_name "
"via --os-tenantname env[OS_TENANT_NAME]"
if getattr(self.options, "stx_auth_type", "keystone") == "oidc":
if not self.options.username:
raise exceptions.CommandError(
"You must provide a username via --os-username "
"env[OS_USERNAME] for OIDC authentication"
)
)
if not self.options.username:
raise exceptions.CommandError(
(
"You must provide a username "
"via --os-username env[OS_USERNAME]"
else:
if not self.options.tenant_name:
raise exceptions.CommandError(
(
"You must provide a tenant_name "
"via --os-tenantname env[OS_TENANT_NAME]"
)
)
if not self.options.username:
raise exceptions.CommandError(
(
"You must provide a username "
"via --os-username env[OS_USERNAME]"
)
)
)
if not self.options.password:
raise exceptions.CommandError(
(
"You must provide a password "
"via --os-password env[OS_PASSWORD]"
if not self.options.password:
raise exceptions.CommandError(
(
"You must provide a password "
"via --os-password env[OS_PASSWORD]"
)
)
)
kwargs = {
"user_domain_name": self.options.user_domain_name,
@@ -568,6 +584,8 @@ class DCManagerShell(app.App):
"project_domain_id": self.options.project_domain_id,
}
auth_type = getattr(self.options, "stx_auth_type", "keystone")
self.client = client.client(
dcmanager_url=self.options.dcmanager_url,
username=self.options.username,
@@ -583,6 +601,7 @@ class DCManagerShell(app.App):
profile=self.options.profile,
refresh_cache=refresh_cache,
cache_allowed=self.options.no_cache is False,
auth_type=auth_type,
**kwargs,
)
@@ -1,7 +1,7 @@
# Copyright 2015 - Huawei Technologies Co., Ltd.
# Copyright 2016 - StackStorm, Inc.
# Copyright 2016 - Ericsson AB.
# Copyright (c) 2017, 2019, 2021, 2024 Wind River Systems, Inc.
# Copyright (c) 2017, 2019, 2021, 2024, 2026 Wind River Systems, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@ import osprofiler.profiler
import testtools
from dcmanagerclient.api import client
from dcmanagerclient.api.v1.client import _build_service_url
AUTH_HTTP_URL = "http://localhost:35357/v3"
AUTH_HTTPS_URL = AUTH_HTTP_URL.replace("http", "https")
@@ -53,7 +54,11 @@ class BaseClientTests(testtools.TestCase):
expected_args = (DCMANAGER_HTTP_URL, token, project_id, user_id)
expected_kwargs = {"cacert": None, "insecure": False}
expected_kwargs = {
"auth_type": "keystone",
"cacert": None,
"insecure": False,
}
client.client(
username="dcmanager",
@@ -81,7 +86,11 @@ class BaseClientTests(testtools.TestCase):
expected_args = (DCMANAGER_HTTPS_URL, token, project_id, user_id)
expected_kwargs = {"cacert": None, "insecure": True}
expected_kwargs = {
"auth_type": "keystone",
"cacert": None,
"insecure": True,
}
client.client(
dcmanager_url=DCMANAGER_HTTPS_URL,
@@ -112,7 +121,11 @@ class BaseClientTests(testtools.TestCase):
expected_args = (DCMANAGER_HTTPS_URL, token, project_id, user_id)
expected_kwargs = {"cacert": path, "insecure": False}
expected_kwargs = {
"auth_type": "keystone",
"cacert": path,
"insecure": False,
}
try:
client.client(
@@ -185,7 +198,11 @@ class BaseClientTests(testtools.TestCase):
expected_args = (DCMANAGER_HTTP_URL, token, project_id, user_id)
expected_kwargs = {"cacert": None, "insecure": False}
expected_kwargs = {
"auth_type": "keystone",
"cacert": None,
"insecure": False,
}
client.client(
username="dcmanager",
@@ -238,3 +255,59 @@ class BaseClientTests(testtools.TestCase):
auth_url=AUTH_HTTP_URL,
**FAKE_KWARGS
)
@mock.patch("dcmanagerclient.api.v1.client._get_oidc_data")
@mock.patch("dcmanagerclient.api.httpclient.HTTPClient")
def test_oidc_auth_success(self, mock_client, mock_oidc_data):
mock_oidc_data.return_value = (DCMANAGER_HTTP_URL, "oidc_token_123")
expected_args = (DCMANAGER_HTTP_URL, "oidc_token_123", None, None)
expected_kwargs = {
"auth_type": "oidc",
"cacert": None,
"insecure": False,
}
client.client(username="test_user", auth_url=AUTH_HTTP_URL, auth_type="oidc")
self.assertTrue(mock_client.called)
self.assertEqual(mock_client.call_args[0], expected_args)
self.assertDictEqual(mock_client.call_args[1], expected_kwargs)
mock_oidc_data.assert_called_once_with(
"test_user",
AUTH_HTTP_URL,
"publicURL",
"dcmanager",
)
def test_oidc_auth_missing_username(self):
self.assertRaises(
RuntimeError, client.client, auth_url=AUTH_HTTP_URL, auth_type="oidc"
)
def test_invalid_auth_type(self):
self.assertRaises(
RuntimeError,
client.client,
username="test_user",
auth_url=AUTH_HTTP_URL,
auth_type="invalid_auth",
)
def test_build_service_url_ipv4_public(self):
result = _build_service_url(
"http://192.168.1.1:5000/v3", "dcmanager", "publicURL"
)
self.assertEqual(result, "https://192.168.1.1:8119/v1.0")
def test_build_service_url_ipv6_admin(self):
result = _build_service_url(
"http://[2001:db8::1]:5000/v3", "dcmanager", "adminURL"
)
self.assertEqual(result, "https://[2001:db8::1]:8120/v1.0")
def test_build_service_url_ipv6_internal(self):
result = _build_service_url(
"http://[fd00::1]:5000/v3", "dcmanager", "internalURL"
)
self.assertEqual(result, "http://[fd00::1]:8119/v1.0")
+2
View File
@@ -11,3 +11,5 @@ python-keystoneclient>=3.8.0 # Apache-2.0
PyYAML>=3.10.0 # MIT
requests!=2.12.2,!=2.13.0,>=2.10.0 # Apache-2.0
requests-toolbelt # Apache-2.0
oic
pyjwkest
+2
View File
@@ -6,6 +6,7 @@ toxworkdir = /tmp/{env:USER}_dc_client_tox
[dcclient]
client_base_dir = .
platform_util_src_dir = ../../utilities/utilities/platform-util/platform-util
[testenv]
basepython = python3.9
@@ -19,6 +20,7 @@ deps =
-c{env:UPPER_CONSTRAINTS_FILE:https://opendev.org/starlingx/root/raw/branch/master/build-tools/requirements/debian/upper-constraints.txt}
-r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
-e{[dcclient]platform_util_src_dir}
allowlist_externals =
rm