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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user