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>
182 lines
5.9 KiB
Python
182 lines
5.9 KiB
Python
# Copyright (c) 2015 Huawei Tech. Co., Ltd.
|
|
# 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
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
#
|
|
|
|
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():
|
|
environ = request.environ
|
|
|
|
oidc_token = request.headers.get("OIDC-Token")
|
|
keystone_token = environ.get("HTTP_X_AUTH_TOKEN")
|
|
|
|
# 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():
|
|
environ = request.environ
|
|
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):
|
|
return getattr(obj, "_pecan", {})
|
|
|
|
|
|
def _is_exposed(obj):
|
|
return getattr(obj, "exposed", False)
|
|
|
|
|
|
def _is_generic(obj):
|
|
data = _get_pecan_data(obj)
|
|
return "generic" in data.keys()
|
|
|
|
|
|
def _is_generic_handler(obj):
|
|
data = _get_pecan_data(obj)
|
|
return "generic_handler" in data.keys()
|
|
|
|
|
|
class GenericPathController(object, metaclass=abc.ABCMeta):
|
|
"""A controller that allows path parameters to be equal to handler names.
|
|
|
|
The _route method provides a custom route resolution that checks if the
|
|
next object is marked as generic or a generic handler, pointing to the
|
|
generic index method in case it is. Pecan will properly handle the rest
|
|
of the routing process by redirecting it to the proper method function
|
|
handler (GET, POST, PATCH, DELETE, etc.).
|
|
|
|
Useful when part of the URL contains path parameters that might have
|
|
the same name as an already defined exposed controller method.
|
|
|
|
Requires the definition of an index method with the generator:
|
|
@expose(generic=True, ...)
|
|
|
|
Does not support nested subcontrollers.
|
|
"""
|
|
|
|
RESERVED_NAMES = ("_route", "_default", "_lookup")
|
|
|
|
@abc.abstractmethod
|
|
def index(self):
|
|
pass
|
|
|
|
@expose()
|
|
def _route(self, remainder, request):
|
|
next_url_part, rest = remainder[0], remainder[1:]
|
|
next_obj = getattr(self, next_url_part, None)
|
|
|
|
is_generic = _is_generic(next_obj) or _is_generic_handler(next_obj)
|
|
is_reserved_name = next_url_part in self.__class__.RESERVED_NAMES
|
|
|
|
if _is_exposed(next_obj) and not is_generic and not is_reserved_name:
|
|
# A non-generic exposed method with a non-reserved name
|
|
return next_obj, rest
|
|
else:
|
|
return self.index, remainder
|