Add OIDC authentication support to DCManager API

This commit introduces OpenID Connect (OIDC) authentication
support to the DCManager API, allowing requests to be authenticated
using either Keystone tokens or OIDC tokens.

Behavior overview:
- The REST API will authenticate using Keystone when the
  `X-Auth-Token` header is provided (existing behavior).
- When the `OIDC-Token` header is provided, OIDC authentication
  is performed instead.
- If both tokens are present, default to Keystone authentication.

For OIDC authentication:
- The REST API retrieves OIDC IDP configuration parameters from
  `system service-parameters` under `kube-apiserver`:
  - `oidc-issuer-url`
  - `oidc-client-id`
  - `oidc-username-claim`
  - `oidc-groups-claim`
- If OIDC parameters are not configured, authentication fails
  with an unauthenticated response.
- If configured, the REST API validates the OIDC token with the
  IDP issuer and extracts claims.
- OIDC arguments and claims are cached.
- External users and groups are mapped to internal
  Project+Role tuples based on StarlingX rolebindings.

Test Plan:
PASS: Authenticate REST API requests with Keystone (`X-Auth-Token`).
PASS: Authenticate REST API requests with OIDC (`OIDC-Token`).
PASS: Verify Keystone is used when both tokens are present.
PASS: Verify unauthenticated response when OIDC parameters are
      missing.
PASS: Validate token claims and role mappings are applied correctly.
PASS: Confirm cached tokens continue to authorize during temporary
      IDP connectivity loss.
PASS: Force to OIDC token validation to return claims as None and verify
      the api returns a NotAuthorized exception.

Depends-On: https://review.opendev.org/c/starlingx/integ/+/970455

Story: 2011646
Task: 53594

Change-Id: I830084fcad9b6413477e703514325030c7dc58a2
Signed-off-by: Hugo Brito <hugo.brito@windriver.com>
This commit is contained in:
Hugo Brito
2025-11-06 13:37:05 -03:00
committed by Raphael
parent d21556aa10
commit 2b198a8009
10 changed files with 208 additions and 47 deletions

View File

@@ -37,6 +37,7 @@
- starlingx/update
- starlingx/config
- starlingx/root
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: py39
@@ -52,6 +53,7 @@
- starlingx/nfv
- starlingx/update
- starlingx/config
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: pylint
@@ -67,6 +69,7 @@
- starlingx/nfv
- starlingx/update
- starlingx/config
- starlingx/utilities
vars:
python_version: 3.9
tox_envlist: pep8

View File

@@ -125,6 +125,7 @@ ignored-modules = cgcs_patch.patch_functions,
nfv_client.openstack,
software.utils,
tsconfig,
platform_util,
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).

View File

@@ -1,5 +1,5 @@
# Copyright (c) 2015 Huawei, Tech. Co,. Ltd.
# Copyright (c) 2017, 2019, 2021, 2024 Wind River Systems, Inc.
# Copyright (c) 2017, 2019, 2021, 2024, 2026 Wind River Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -15,6 +15,7 @@
# under the License.
#
import os
import pecan
from keystonemiddleware import auth_token
@@ -58,22 +59,33 @@ def setup_app(*args, **kwargs):
def _wrap_app(app):
app = request_id.RequestId(app)
if cfg.CONF.pecan.auth_enable and cfg.CONF.auth_strategy == "keystone":
conf = dict(cfg.CONF.keystone_authtoken)
# Change auth decisions of requests to the app itself.
conf.update({"delay_auth_decision": True})
# NOTE: Policy enforcement works only if Keystone
# authentication is enabled. No support for other authentication
# types at this point.
return auth_token.AuthProtocol(app, conf)
else:
return app
if cfg.CONF.pecan.auth_enable:
auth_type = _get_auth_type()
# For Keystone or OIDC, we still need Keystone middleware
# OIDC validation is handled in AuthHook
if auth_type in ["keystone", "oidc"]:
conf = dict(cfg.CONF.keystone_authtoken)
conf.update({"delay_auth_decision": True})
# Policy enforcement works for both Keystone and OIDC
return auth_token.AuthProtocol(app, conf)
return app
_launcher = None
def _get_auth_type():
"""Get authentication type from environment or config"""
env_auth_type = os.environ.get("STX_AUTH_TYPE")
if env_auth_type and env_auth_type.lower() in ["keystone", "oidc"]:
return env_auth_type.lower()
return cfg.CONF.auth_strategy
def serve(api_service, conf, workers=1):
global _launcher
if _launcher:

View File

@@ -1,5 +1,5 @@
# Copyright (c) 2015 Huawei Tech. Co., Ltd.
# Copyright (c) 2017-2022, 2024 Wind River Systems, Inc.
# Copyright (c) 2017-2022, 2024, 2026 Wind River Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -17,43 +17,111 @@
import abc
from oslo_log import log as logging
from pecan import expose
from pecan import request
from platform_util.oidc import oidc_utils
import dcmanager.common.context as k_context
from dcmanager.common.exceptions import NotAuthorized
LOG = logging.getLogger(__name__)
# For OIDC use default project
_ADMIN_PROJECT_NAME = "admin"
def extract_context_from_environ():
context_paras = {
"auth_token": "HTTP_X_AUTH_TOKEN",
"user": "HTTP_X_USER_ID",
"project": "HTTP_X_TENANT_ID",
"user_name": "HTTP_X_USER_NAME",
"tenant_name": "HTTP_X_PROJECT_NAME",
"domain": "HTTP_X_DOMAIN_ID",
"roles": "HTTP_X_ROLE",
"user_domain": "HTTP_X_USER_DOMAIN_ID",
"project_domain": "HTTP_X_PROJECT_DOMAIN_ID",
"request_id": "openstack.request_id",
}
environ = request.environ
for key, val in context_paras.items():
context_paras[key] = environ.get(val)
role = environ.get("HTTP_X_ROLE")
oidc_token = request.headers.get("OIDC-Token")
keystone_token = environ.get("HTTP_X_AUTH_TOKEN")
context_paras["is_admin"] = "admin" in role.split(",")
return k_context.RequestContext(**context_paras)
# The default authentication method is always keystone, so, if the user provides
# both tokens, the authentication should be performed with keystone, ignoring the
# stx-auth-type parameter provided in the header.
# Note that the dcmanager-client will not generate a scenario where both are sent
# simultaneously.
if keystone_token:
context_paras = {
"auth_token": "HTTP_X_AUTH_TOKEN",
"user": "HTTP_X_USER_ID",
"project": "HTTP_X_TENANT_ID",
"user_name": "HTTP_X_USER_NAME",
"tenant_name": "HTTP_X_PROJECT_NAME",
"domain": "HTTP_X_DOMAIN_ID",
"roles": "HTTP_X_ROLE",
"user_domain": "HTTP_X_USER_DOMAIN_ID",
"project_domain": "HTTP_X_PROJECT_DOMAIN_ID",
"request_id": "openstack.request_id",
}
for key, val in context_paras.items():
context_paras[key] = environ.get(val)
role = environ.get("HTTP_X_ROLE")
context_paras["is_admin"] = "admin" in role.split(",")
context_paras["auth_type"] = "keystone"
return k_context.RequestContext(**context_paras)
# OIDC RequestContext
username, roles = parse_oidc_token_claims(oidc_token)
return k_context.RequestContext(
auth_type="oidc",
oidc_token=oidc_token,
user_name=username,
project_name=_ADMIN_PROJECT_NAME,
roles=roles,
is_admin=("admin" in roles),
request_id=environ.get("openstack.request_id"),
)
def extract_credentials_for_policy():
context_paras = {"project_name": "HTTP_X_PROJECT_NAME", "roles": "HTTP_X_ROLE"}
environ = request.environ
for key, val in context_paras.items():
context_paras[key] = environ.get(val)
context_paras["roles"] = context_paras["roles"].split(",")
return context_paras
oidc_token = request.headers.get("OIDC-Token")
keystone_token = environ.get("HTTP_X_AUTH_TOKEN")
if keystone_token:
context_paras = {"project_name": "HTTP_X_PROJECT_NAME", "roles": "HTTP_X_ROLE"}
for key, val in context_paras.items():
context_paras[key] = environ.get(val)
context_paras["roles"] = context_paras["roles"].split(",")
return context_paras
# OIDC Credentials
_, roles = parse_oidc_token_claims(oidc_token)
return {"project_name": _ADMIN_PROJECT_NAME, "roles": roles}
def parse_oidc_token_claims(oidc_token):
"""Return (username, roles) using a tiny shared cache."""
oidc_config = k_context.get_oidc_args_cached(k_context._oidc_cache)
with k_context._oidc_cache_lock:
token_bucket = k_context._oidc_cache.setdefault("oidc_tokens", {})
claims = oidc_utils.validate_oidc_token(
oidc_token,
token_bucket,
oidc_config["oidc-issuer-url"],
oidc_config["oidc-client-id"],
)
if not claims:
LOG.error("OIDC token validation failed")
raise NotAuthorized()
username = oidc_utils.get_username_from_oidc_token(
claims, oidc_config["oidc-username-claim"]
)
roles = oidc_utils.get_keystone_roles_for_oidc_token(
claims,
oidc_config["oidc-username-claim"],
oidc_config["oidc-groups-claim"],
)
return username, roles
def _get_pecan_data(obj):

View File

@@ -1,4 +1,4 @@
# Copyright (c) 2017-2022, 2024-2025 Wind River Systems, Inc.
# Copyright (c) 2017-2022, 2024-2026 Wind River Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -15,6 +15,7 @@
#
import re
import threading
import time
from urllib.parse import urlparse
@@ -24,6 +25,7 @@ from oslo_utils import encodeutils
from oslo_utils import uuidutils
import pecan
from pecan import hooks
from platform_util.oidc import oidc_utils
from dcmanager.api.policies import base as base_policy
from dcmanager.api import policy
@@ -34,11 +36,35 @@ ALLOWED_WITHOUT_AUTH = "/"
audit_log_name = "{}.{}".format(__name__, "auditor")
auditLOG = log.getLogger(audit_log_name)
# OIDC Cache
_oidc_cache = {}
_oidc_cache_lock = threading.Lock()
# OIDC args cache timeout
OIDC_ARGS_TIMEOUT = 86400
def generate_request_id():
return "req-%s" % uuidutils.generate_uuid()
def get_oidc_args_cached(cache: dict) -> dict:
"""Return OIDC args cache"""
with _oidc_cache_lock:
cfg = cache.get("oidc_args")
ts = cache.get("oidc_args_ts", 0.0)
now = time.monotonic()
# Update OIDC Config every day
if not cfg or (now - ts) >= OIDC_ARGS_TIMEOUT:
auditLOG.debug("Updating OIDC args cache")
cfg = oidc_utils.get_apiserver_oidc_args()
cache["oidc_args"] = cfg
cache["oidc_args_ts"] = now
return cfg
class RequestContext(base_context.RequestContext):
"""Stores information about the security context.
@@ -110,6 +136,9 @@ class RequestContext(base_context.RequestContext):
self.roles = roles or []
self.password = password
self.oidc_token = kwargs.get("oidc_token")
self.auth_type = kwargs.get("auth_type", "keystone")
# Check user is admin or not
if is_admin is None:
self.is_admin = policy.authorize(
@@ -146,6 +175,8 @@ class RequestContext(base_context.RequestContext):
"is_admin": self.is_admin,
"request_id": self.request_id,
"password": self.password,
"oidc_token": self.oidc_token,
"auth_type": self.auth_type,
}
@classmethod
@@ -166,25 +197,65 @@ def get_service_context(**args):
this credential refers to the credentials built for dcmanager middleware
in an OpenStack cloud.
"""
pass
class AuthHook(hooks.PecanHook):
def before(self, state):
if state.request.path == ALLOWED_WITHOUT_AUTH:
return
req = state.request
identity_status = req.headers.get("X-Identity-Status")
service_identity_status = req.headers.get("X-Service-Identity-Status")
if identity_status == "Confirmed" or service_identity_status == "Confirmed":
return
if req.headers.get("X-Auth-Token"):
msg = "Auth token is invalid: %s" % req.headers["X-Auth-Token"]
keystone_token = req.headers.get("X-Auth-Token")
oidc_token = req.headers.get("OIDC-Token")
if keystone_token:
identity_status = req.headers.get("X-Identity-Status")
service_identity_status = req.headers.get("X-Service-Identity-Status")
if identity_status == "Confirmed" or service_identity_status == "Confirmed":
return
msg = "Auth token is invalid: %s" % keystone_token
elif oidc_token:
if self._validate_oidc_auth(oidc_token):
return
msg = "OIDC token is invalid"
else:
msg = "Authentication required"
msg = "Failed to validate access token: %s" % str(msg)
pecan.abort(status_code=401, detail=msg)
def _validate_oidc_auth(self, oidc_token):
"""Validate OIDC token using platform utilities"""
if not oidc_token:
auditLOG.debug("No OIDC token provided")
return False
try:
oidc_config = get_oidc_args_cached(_oidc_cache)
if not oidc_config:
auditLOG.debug("OIDC configuration not available")
return False
issuer_url = oidc_config.get("oidc-issuer-url")
client_id = oidc_config.get("oidc-client-id")
if not issuer_url or not client_id:
auditLOG.debug("OIDC configuration incomplete")
return False
with _oidc_cache_lock:
token_bucket = _oidc_cache.setdefault("oidc_tokens", {})
token_claims = oidc_utils.validate_oidc_token(
oidc_token, token_bucket, issuer_url, client_id
)
return token_claims is not None
except Exception as e:
auditLOG.debug(f"OIDC validation failed: {e}")
return False
class AuditLoggingHook(hooks.PecanHook):
"""Request data logging.

View File

@@ -1,5 +1,5 @@
# Copyright (c) 2017 Ericsson AB
# Copyright (c) 2020-2022, 2024 Wind River Systems, Inc.
# Copyright (c) 2020-2022, 2024, 2026 Wind River Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -30,6 +30,7 @@ class APIMixin(object):
FAKE_TENANT = utils.UUID1
api_headers = {
"X-Auth-Token": "fake_keystone_token",
"X-Tenant-Id": FAKE_TENANT,
"X_ROLE": "admin,member,reader",
"X-Identity-Status": "Confirmed",

View File

@@ -1,5 +1,5 @@
# Copyright (c) 2015 Huawei Technologies Co., Ltd.
# Copyright (c) 2017-2025 Wind River Systems, Inc.
# Copyright (c) 2017-2026 Wind River Systems, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
@@ -66,6 +66,7 @@ class DCManagerApiTest(DCManagerTestCase):
self.upload_files = None
self.verb = None
self.headers = {
"X-Auth-Token": "fake_keystone_token",
"X-Tenant-Id": utils.UUID1,
"X_ROLE": "admin,member,reader",
"X-Identity-Status": "Confirmed",

View File

@@ -1,5 +1,5 @@
#
# Copyright (c) 2020-2025 Wind River Systems, Inc.
# Copyright (c) 2020-2026 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
@@ -21,6 +21,7 @@ WRONG_URL = "/v1.0/wrong"
FAKE_SOFTWARE_VERSION = "10.0"
FAKE_HEADERS = {
"X-Auth-Token": "fake_keystone_token",
"X-Tenant-Id": FAKE_TENANT,
"X_ROLE": "admin,member,reader",
"X-Identity-Status": "Confirmed",

View File

@@ -42,3 +42,5 @@ sh # MIT
sqlalchemy!=1.1.5,!=1.1.6,!=1.1.7,!=1.1.8,>=1.0.10 # MIT
sqlalchemy-migrate>=0.11.0 # Apache-2.0
webob>=1.7.1 # MIT
oic
pyjwkest

View File

@@ -17,6 +17,7 @@ nfv_client_src_dir = ../../nfv/nfv/nfv-client
tsconfig_src_dir = {[dc]stx_config_dir}/tsconfig/tsconfig
software_src_dir = ../../update/software
sysinv_src_dir = {[dc]stx_config_dir}/sysinv/sysinv/sysinv
platform_util_src_dir = ../../utilities/utilities/platform-util/platform-util
[testenv]
basepython = python3.9
@@ -39,7 +40,7 @@ deps =
-e{[dc]nfv_client_src_dir}
-e{[dc]tsconfig_src_dir}
-e{[dc]software_src_dir}
-e{[dc]sysinv_src_dir}
-e{[dc]platform_util_src_dir}
allowlist_externals =
rm
find