diff --git a/.zuul.yaml b/.zuul.yaml index 979fb10d5..9508f45f3 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -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 diff --git a/distributedcloud/.pylintrc b/distributedcloud/.pylintrc index 7b2443f78..e196f0cee 100644 --- a/distributedcloud/.pylintrc +++ b/distributedcloud/.pylintrc @@ -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). diff --git a/distributedcloud/dcmanager/api/app.py b/distributedcloud/dcmanager/api/app.py index 7803f14d3..a51caf04e 100644 --- a/distributedcloud/dcmanager/api/app.py +++ b/distributedcloud/dcmanager/api/app.py @@ -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: diff --git a/distributedcloud/dcmanager/api/controllers/restcomm.py b/distributedcloud/dcmanager/api/controllers/restcomm.py index 77059e634..16531b28d 100644 --- a/distributedcloud/dcmanager/api/controllers/restcomm.py +++ b/distributedcloud/dcmanager/api/controllers/restcomm.py @@ -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): diff --git a/distributedcloud/dcmanager/common/context.py b/distributedcloud/dcmanager/common/context.py index 0d3094b8d..51568d74b 100644 --- a/distributedcloud/dcmanager/common/context.py +++ b/distributedcloud/dcmanager/common/context.py @@ -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. diff --git a/distributedcloud/dcmanager/tests/unit/api/controllers/v1/mixins.py b/distributedcloud/dcmanager/tests/unit/api/controllers/v1/mixins.py index 8022329c3..5a35e5adf 100644 --- a/distributedcloud/dcmanager/tests/unit/api/controllers/v1/mixins.py +++ b/distributedcloud/dcmanager/tests/unit/api/controllers/v1/mixins.py @@ -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", diff --git a/distributedcloud/dcmanager/tests/unit/api/test_root_controller.py b/distributedcloud/dcmanager/tests/unit/api/test_root_controller.py index 989955a3b..be99426ea 100644 --- a/distributedcloud/dcmanager/tests/unit/api/test_root_controller.py +++ b/distributedcloud/dcmanager/tests/unit/api/test_root_controller.py @@ -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", diff --git a/distributedcloud/dcmanager/tests/unit/common/fake_subcloud.py b/distributedcloud/dcmanager/tests/unit/common/fake_subcloud.py index 0e0e382c6..508222d02 100644 --- a/distributedcloud/dcmanager/tests/unit/common/fake_subcloud.py +++ b/distributedcloud/dcmanager/tests/unit/common/fake_subcloud.py @@ -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", diff --git a/distributedcloud/requirements.txt b/distributedcloud/requirements.txt index 4bfe2d64a..9322e1a15 100644 --- a/distributedcloud/requirements.txt +++ b/distributedcloud/requirements.txt @@ -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 diff --git a/distributedcloud/tox.ini b/distributedcloud/tox.ini index c29cbdc27..63d314cb1 100644 --- a/distributedcloud/tox.ini +++ b/distributedcloud/tox.ini @@ -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