Skip to content

Commit 2dbaf0d

Browse files
CWFHEALTH-4934: add trusted-proxy authentication module
Add a new auth module that validates requests forwarded by a trusted Kubernetes proxy. The proxy authenticates itself via a ServiceAccount token (validated through TokenReview), then the end user's identity is extracted from a configurable HTTP header (default: X-Forwarded-User). Key behaviors: - Optional SA allowlist restricts which proxies may forward requests - Health probe and metrics endpoints can skip auth when configured - Authorization resolvers use the same pattern as rh-identity (NoopRoles + GenericAccess when access rules are defined) Signed-off-by: Willian Rampazzo <willianr@redhat.com>
1 parent 82f6ca4 commit 2dbaf0d

10 files changed

Lines changed: 824 additions & 4 deletions

File tree

docs/openapi.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11613,6 +11613,16 @@
1161311613
"type": "null"
1161411614
}
1161511615
]
11616+
},
11617+
"trusted_proxy_config": {
11618+
"anyOf": [
11619+
{
11620+
"$ref": "#/components/schemas/TrustedProxyConfiguration"
11621+
},
11622+
{
11623+
"type": "null"
11624+
}
11625+
]
1161611626
}
1161711627
},
1161811628
"additionalProperties": false,
@@ -19757,6 +19767,57 @@
1975719767
}
1975819768
]
1975919769
},
19770+
"TrustedProxyConfiguration": {
19771+
"properties": {
19772+
"user_header": {
19773+
"type": "string",
19774+
"title": "User identity header",
19775+
"description": "HTTP header containing the forwarded user identity.",
19776+
"default": "X-Forwarded-User"
19777+
},
19778+
"allowed_service_accounts": {
19779+
"anyOf": [
19780+
{
19781+
"items": {
19782+
"$ref": "#/components/schemas/TrustedProxyServiceAccount"
19783+
},
19784+
"type": "array"
19785+
},
19786+
{
19787+
"type": "null"
19788+
}
19789+
],
19790+
"title": "Allowed service accounts",
19791+
"description": "Optional allowlist of Kubernetes ServiceAccount identities permitted to act as trusted proxies."
19792+
}
19793+
},
19794+
"additionalProperties": false,
19795+
"type": "object",
19796+
"title": "TrustedProxyConfiguration",
19797+
"description": "Configuration for trusted-proxy auth module."
19798+
},
19799+
"TrustedProxyServiceAccount": {
19800+
"properties": {
19801+
"namespace": {
19802+
"type": "string",
19803+
"title": "Namespace",
19804+
"description": "Kubernetes namespace of the ServiceAccount."
19805+
},
19806+
"name": {
19807+
"type": "string",
19808+
"title": "Name",
19809+
"description": "Name of the Kubernetes ServiceAccount."
19810+
}
19811+
},
19812+
"additionalProperties": false,
19813+
"type": "object",
19814+
"required": [
19815+
"namespace",
19816+
"name"
19817+
],
19818+
"title": "TrustedProxyServiceAccount",
19819+
"description": "A Kubernetes ServiceAccount identity for trusted-proxy allowlist."
19820+
},
1976019821
"UnauthorizedResponse": {
1976119822
"properties": {
1976219823
"status_code": {

src/authentication/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
noop,
1111
noop_with_token,
1212
rh_identity,
13+
trusted_proxy,
1314
)
1415
from authentication.interface import AuthInterface
1516
from configuration import LogicError, configuration
@@ -18,7 +19,7 @@
1819
logger = get_logger(__name__)
1920

2021

21-
def get_auth_dependency(
22+
def get_auth_dependency( # pylint: disable=too-many-return-statements
2223
virtual_path: str = constants.DEFAULT_VIRTUAL_PATH,
2324
) -> AuthInterface:
2425
"""Select the configured authentication dependency interface.
@@ -82,6 +83,11 @@ def get_auth_dependency(
8283
config=configuration.authentication_configuration.api_key_configuration,
8384
virtual_path=virtual_path,
8485
)
86+
case constants.AUTH_MOD_TRUSTED_PROXY:
87+
return trusted_proxy.TrustedProxyAuthDependency(
88+
config=configuration.authentication_configuration.trusted_proxy_configuration,
89+
virtual_path=virtual_path,
90+
)
8591
case _:
8692
err_msg = f"Unsupported authentication module '{module}'"
8793
logger.error(err_msg)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Trusted-proxy authentication module for requests forwarded by a K8s proxy."""
2+
3+
from typing import cast
4+
5+
import kubernetes.client
6+
from fastapi import HTTPException, Request
7+
8+
from authentication.interface import NO_AUTH_TUPLE, AuthInterface, AuthTuple
9+
from authentication.k8s import get_user_info
10+
from authentication.utils import extract_user_token
11+
from configuration import configuration
12+
from constants import DEFAULT_VIRTUAL_PATH, NO_USER_TOKEN
13+
from log import get_logger
14+
from models.api.responses.error import ForbiddenResponse, UnauthorizedResponse
15+
from models.config import TrustedProxyConfiguration
16+
17+
logger = get_logger(__name__)
18+
19+
20+
class TrustedProxyAuthDependency(
21+
AuthInterface
22+
): # pylint: disable=too-few-public-methods
23+
"""FastAPI dependency for trusted-proxy authentication.
24+
25+
Validates that the caller is an expected Kubernetes ServiceAccount
26+
via TokenReview, then extracts the end user's identity from a
27+
configurable HTTP header set by the proxy.
28+
"""
29+
30+
def __init__(
31+
self,
32+
config: TrustedProxyConfiguration,
33+
virtual_path: str = DEFAULT_VIRTUAL_PATH,
34+
) -> None:
35+
"""Initialize the trusted-proxy authentication dependency.
36+
37+
Parameters:
38+
----------
39+
config: Trusted-proxy configuration with user header
40+
and optional SA allowlist.
41+
virtual_path: The request path used for authorization checks;
42+
defaults to DEFAULT_VIRTUAL_PATH.
43+
"""
44+
self.config = config
45+
self.virtual_path = virtual_path
46+
self.skip_userid_check = True
47+
48+
async def __call__(self, request: Request) -> AuthTuple:
49+
"""Validate the proxy's SA token and extract forwarded user identity.
50+
51+
Parameters:
52+
----------
53+
request: The FastAPI request object.
54+
55+
Returns:
56+
-------
57+
AuthTuple with the forwarded user identity.
58+
59+
Raises:
60+
------
61+
HTTPException: If authentication fails.
62+
"""
63+
if not request.headers.get("Authorization"):
64+
if configuration.authentication_configuration.skip_for_health_probes:
65+
if request.url.path in ("/readiness", "/liveness"):
66+
return NO_AUTH_TUPLE
67+
if configuration.authentication_configuration.skip_for_metrics:
68+
if request.url.path == "/metrics":
69+
return NO_AUTH_TUPLE
70+
response = UnauthorizedResponse(cause="Missing Authorization header")
71+
raise HTTPException(**response.model_dump())
72+
73+
token = extract_user_token(request.headers)
74+
user_info = get_user_info(token)
75+
76+
if user_info is None:
77+
response = UnauthorizedResponse(
78+
cause="Invalid or expired proxy service account token"
79+
)
80+
raise HTTPException(**response.model_dump())
81+
82+
user = cast(kubernetes.client.V1UserInfo, user_info.user)
83+
if not user or not hasattr(user, "username"):
84+
response = UnauthorizedResponse(
85+
cause="Invalid service account token: missing user information"
86+
)
87+
raise HTTPException(**response.model_dump())
88+
89+
sa_username = cast(str, user.username)
90+
if not sa_username:
91+
response = UnauthorizedResponse(
92+
cause="Invalid service account token: missing username"
93+
)
94+
raise HTTPException(**response.model_dump())
95+
96+
if self.config.allowed_service_accounts:
97+
allowed = {
98+
f"system:serviceaccount:{sa.namespace}:{sa.name}"
99+
for sa in self.config.allowed_service_accounts
100+
}
101+
if sa_username not in allowed:
102+
logger.warning(
103+
"Service account '%s' is not in the trusted-proxy allowlist",
104+
sa_username,
105+
)
106+
response = ForbiddenResponse.endpoint(user_id=sa_username)
107+
raise HTTPException(**response.model_dump())
108+
109+
forwarded_user = (request.headers.get(self.config.user_header) or "").strip()
110+
if not forwarded_user:
111+
response = UnauthorizedResponse(
112+
cause=f"Missing required header '{self.config.user_header}'"
113+
)
114+
raise HTTPException(**response.model_dump())
115+
116+
logger.debug(
117+
"Trusted-proxy auth: proxy='%s', forwarded_user='%s'",
118+
sa_username,
119+
forwarded_user,
120+
)
121+
122+
return (
123+
forwarded_user,
124+
forwarded_user,
125+
self.skip_userid_check,
126+
NO_USER_TOKEN,
127+
)

src/authorization/middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def get_authorization_resolvers() -> tuple[RolesResolver, AccessResolver]:
7979
GenericAccessResolver(authorization_cfg.access_rules),
8080
)
8181

82-
case constants.AUTH_MOD_RH_IDENTITY:
82+
case constants.AUTH_MOD_RH_IDENTITY | constants.AUTH_MOD_TRUSTED_PROXY:
8383
# rh-identity uses access rules for authorization, but doesn't extract
8484
# roles from the identity header - all authenticated users get the "*" role
8585
if len(authorization_cfg.access_rules) == 0:

src/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
AUTH_MOD_APIKEY_TOKEN: Final[str] = "api-key-token"
120120
AUTH_MOD_JWK_TOKEN: Final[str] = "jwk-token"
121121
AUTH_MOD_RH_IDENTITY: Final[str] = "rh-identity"
122+
AUTH_MOD_TRUSTED_PROXY: Final[str] = "trusted-proxy"
122123
# Supported authentication modules
123124
SUPPORTED_AUTHENTICATION_MODULES: Final[frozenset[str]] = frozenset(
124125
{
@@ -128,6 +129,7 @@
128129
AUTH_MOD_JWK_TOKEN,
129130
AUTH_MOD_APIKEY_TOKEN,
130131
AUTH_MOD_RH_IDENTITY,
132+
AUTH_MOD_TRUSTED_PROXY,
131133
}
132134
)
133135
DEFAULT_AUTHENTICATION_MODULE: Final[str] = AUTH_MOD_NOOP

src/models/config.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,37 @@ class APIKeyTokenConfiguration(ConfigurationBase):
12651265
)
12661266

12671267

1268+
class TrustedProxyServiceAccount(ConfigurationBase):
1269+
"""A Kubernetes ServiceAccount identity for trusted-proxy allowlist."""
1270+
1271+
namespace: str = Field(
1272+
...,
1273+
title="Namespace",
1274+
description="Kubernetes namespace of the ServiceAccount.",
1275+
)
1276+
name: str = Field(
1277+
...,
1278+
title="Name",
1279+
description="Name of the Kubernetes ServiceAccount.",
1280+
)
1281+
1282+
1283+
class TrustedProxyConfiguration(ConfigurationBase):
1284+
"""Configuration for trusted-proxy auth module."""
1285+
1286+
user_header: str = Field(
1287+
"X-Forwarded-User",
1288+
title="User identity header",
1289+
description="HTTP header containing the forwarded user identity.",
1290+
)
1291+
allowed_service_accounts: Optional[list[TrustedProxyServiceAccount]] = Field(
1292+
None,
1293+
title="Allowed service accounts",
1294+
description="Optional allowlist of Kubernetes ServiceAccount identities "
1295+
"permitted to act as trusted proxies.",
1296+
)
1297+
1298+
12681299
class AuthenticationConfiguration(ConfigurationBase):
12691300
"""Authentication configuration."""
12701301

@@ -1287,6 +1318,7 @@ class AuthenticationConfiguration(ConfigurationBase):
12871318
jwk_config: Optional[JwkConfiguration] = None
12881319
api_key_config: Optional[APIKeyTokenConfiguration] = None
12891320
rh_identity_config: Optional[RHIdentityConfiguration] = None
1321+
trusted_proxy_config: Optional[TrustedProxyConfiguration] = None
12901322

12911323
@model_validator(mode="after")
12921324
def check_authentication_model(self) -> Self:
@@ -1335,6 +1367,13 @@ def check_authentication_model(self) -> Self:
13351367
"api_key parameter must be specified when using API_KEY token authentication"
13361368
)
13371369

1370+
if self.module == constants.AUTH_MOD_TRUSTED_PROXY:
1371+
if self.trusted_proxy_config is None:
1372+
raise ValueError(
1373+
"Trusted proxy configuration must be specified "
1374+
"when using trusted-proxy authentication"
1375+
)
1376+
13381377
return self
13391378

13401379
@property
@@ -1388,6 +1427,26 @@ def api_key_configuration(self) -> APIKeyTokenConfiguration:
13881427
raise ValueError("API Key configuration should not be None")
13891428
return self.api_key_config
13901429

1430+
@property
1431+
def trusted_proxy_configuration(self) -> TrustedProxyConfiguration:
1432+
"""Return trusted-proxy configuration if the module is trusted-proxy.
1433+
1434+
Returns:
1435+
TrustedProxyConfiguration: The configured trusted-proxy settings.
1436+
1437+
Raises:
1438+
ValueError: If the active authentication module is not trusted-proxy.
1439+
ValueError: If the trusted-proxy configuration is missing.
1440+
"""
1441+
if self.module != constants.AUTH_MOD_TRUSTED_PROXY:
1442+
raise ValueError(
1443+
"Trusted proxy configuration is only available "
1444+
"for trusted-proxy authentication module"
1445+
)
1446+
if self.trusted_proxy_config is None:
1447+
raise ValueError("Trusted proxy configuration should not be None")
1448+
return self.trusted_proxy_config
1449+
13911450

13921451
@dataclass
13931452
class CustomProfile:

tests/unit/authentication/test_auth.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
"""Unit tests for functions defined in authentication/__init__.py"""
22

3-
from authentication import get_auth_dependency, k8s, noop, noop_with_token
3+
from authentication import (
4+
get_auth_dependency,
5+
k8s,
6+
noop,
7+
noop_with_token,
8+
trusted_proxy,
9+
)
410
from configuration import configuration
5-
from constants import AUTH_MOD_K8S, AUTH_MOD_NOOP, AUTH_MOD_NOOP_WITH_TOKEN
11+
from constants import (
12+
AUTH_MOD_K8S,
13+
AUTH_MOD_NOOP,
14+
AUTH_MOD_NOOP_WITH_TOKEN,
15+
AUTH_MOD_TRUSTED_PROXY,
16+
)
17+
from models.config import TrustedProxyConfiguration
618

719

820
def test_get_auth_dependency_noop() -> None:
@@ -27,3 +39,14 @@ def test_get_auth_dependency_k8s() -> None:
2739
configuration.authentication_configuration.module = AUTH_MOD_K8S
2840
auth_dependency = get_auth_dependency()
2941
assert isinstance(auth_dependency, k8s.K8SAuthDependency)
42+
43+
44+
def test_get_auth_dependency_trusted_proxy() -> None:
45+
"""Test getting trusted-proxy authentication dependency."""
46+
assert configuration.authentication_configuration is not None
47+
configuration.authentication_configuration.module = AUTH_MOD_TRUSTED_PROXY
48+
configuration.authentication_configuration.trusted_proxy_config = (
49+
TrustedProxyConfiguration()
50+
)
51+
auth_dependency = get_auth_dependency()
52+
assert isinstance(auth_dependency, trusted_proxy.TrustedProxyAuthDependency)

0 commit comments

Comments
 (0)