Skip to content

Commit ad85ff7

Browse files
authored
Merge pull request #1887 from willianrampazzo/trusted_proxy_auth_module
CWFHEALTH-4934: add trusted-proxy authentication module
2 parents b4216ef + 091a76d commit ad85ff7

10 files changed

Lines changed: 827 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,
@@ -19825,6 +19835,57 @@
1982519835
}
1982619836
]
1982719837
},
19838+
"TrustedProxyConfiguration": {
19839+
"properties": {
19840+
"user_header": {
19841+
"type": "string",
19842+
"title": "User identity header",
19843+
"description": "HTTP header containing the forwarded user identity.",
19844+
"default": "X-Forwarded-User"
19845+
},
19846+
"allowed_service_accounts": {
19847+
"anyOf": [
19848+
{
19849+
"items": {
19850+
"$ref": "#/components/schemas/TrustedProxyServiceAccount"
19851+
},
19852+
"type": "array"
19853+
},
19854+
{
19855+
"type": "null"
19856+
}
19857+
],
19858+
"title": "Allowed service accounts",
19859+
"description": "Optional allowlist of Kubernetes ServiceAccount identities permitted to act as trusted proxies. When set to null/omitted, any ServiceAccount with a valid token is accepted. When set to a non-empty list, only the listed ServiceAccounts are allowed. An empty list behaves the same as null (no restriction)."
19860+
}
19861+
},
19862+
"additionalProperties": false,
19863+
"type": "object",
19864+
"title": "TrustedProxyConfiguration",
19865+
"description": "Configuration for trusted-proxy auth module."
19866+
},
19867+
"TrustedProxyServiceAccount": {
19868+
"properties": {
19869+
"namespace": {
19870+
"type": "string",
19871+
"title": "Namespace",
19872+
"description": "Kubernetes namespace of the ServiceAccount."
19873+
},
19874+
"name": {
19875+
"type": "string",
19876+
"title": "Name",
19877+
"description": "Name of the Kubernetes ServiceAccount."
19878+
}
19879+
},
19880+
"additionalProperties": false,
19881+
"type": "object",
19882+
"required": [
19883+
"namespace",
19884+
"name"
19885+
],
19886+
"title": "TrustedProxyServiceAccount",
19887+
"description": "A Kubernetes ServiceAccount identity for trusted-proxy allowlist."
19888+
},
1982819889
"UnauthorizedResponse": {
1982919890
"properties": {
1983019891
"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_present=%s",
118+
sa_username,
119+
True,
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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,40 @@ 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+
"When set to null/omitted, any ServiceAccount with a valid token is accepted. "
1297+
"When set to a non-empty list, only the listed ServiceAccounts are allowed. "
1298+
"An empty list behaves the same as null (no restriction).",
1299+
)
1300+
1301+
12681302
class AuthenticationConfiguration(ConfigurationBase):
12691303
"""Authentication configuration."""
12701304

@@ -1287,6 +1321,7 @@ class AuthenticationConfiguration(ConfigurationBase):
12871321
jwk_config: Optional[JwkConfiguration] = None
12881322
api_key_config: Optional[APIKeyTokenConfiguration] = None
12891323
rh_identity_config: Optional[RHIdentityConfiguration] = None
1324+
trusted_proxy_config: Optional[TrustedProxyConfiguration] = None
12901325

12911326
@model_validator(mode="after")
12921327
def check_authentication_model(self) -> Self:
@@ -1335,6 +1370,13 @@ def check_authentication_model(self) -> Self:
13351370
"api_key parameter must be specified when using API_KEY token authentication"
13361371
)
13371372

1373+
if self.module == constants.AUTH_MOD_TRUSTED_PROXY:
1374+
if self.trusted_proxy_config is None:
1375+
raise ValueError(
1376+
"Trusted proxy configuration must be specified "
1377+
"when using trusted-proxy authentication"
1378+
)
1379+
13381380
return self
13391381

13401382
@property
@@ -1388,6 +1430,26 @@ def api_key_configuration(self) -> APIKeyTokenConfiguration:
13881430
raise ValueError("API Key configuration should not be None")
13891431
return self.api_key_config
13901432

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

13921454
@dataclass
13931455
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)