-
Notifications
You must be signed in to change notification settings - Fork 97
Expand file tree
/
Copy pathrh_identity.py
More file actions
452 lines (377 loc) · 17.1 KB
/
Copy pathrh_identity.py
File metadata and controls
452 lines (377 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
"""Red Hat Identity header authentication for FastAPI endpoints.
This module provides authentication via the x-rh-identity header, supporting
User, System, and ServiceAccount identity types with optional entitlement
validation.
"""
import base64
import json
from typing import Any, Optional
from fastapi import HTTPException, Request
from authentication.interface import NO_AUTH_TUPLE, AuthInterface, AuthTuple
from configuration import configuration
from constants import (
DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
DEFAULT_VIRTUAL_PATH,
NO_USER_TOKEN,
)
from log import get_logger
logger = get_logger(__name__)
RH_INSIGHTS_REQUEST_ID_HEADER = "x-rh-insights-request-id"
REQUEST_ID_HEADER = "x-request-id"
def _get_request_id(request: Request) -> str:
"""Return the inbound request identifier available during authentication."""
return request.headers.get(RH_INSIGHTS_REQUEST_ID_HEADER) or request.headers.get(
REQUEST_ID_HEADER,
"",
)
class RHIdentityData:
"""Extracts and validates Red Hat Identity header data.
Supports three identity types:
- User: Console users with user_id, username, is_org_admin
- System: Certificate-authenticated RHEL systems with cn as identifier
- ServiceAccount: OAuth service accounts with client_id, username, user_id
"""
def __init__(
self,
identity_data: dict,
required_entitlements: Optional[list[str]] = None,
) -> None:
"""Initialize RH Identity data extractor.
Args:
identity_data: Decoded JSON from x-rh-identity header
required_entitlements: Service entitlements to validate (optional)
Raises:
HTTPException: If validation fails (400 for format errors, 403 for entitlements)
"""
self.identity_data = identity_data
self.required_entitlements = required_entitlements or []
self._validate_structure()
def _validate_structure(self) -> None:
"""Validate the identity data structure.
Raises:
HTTPException: 400 if required fields are missing or malformed
"""
if (
"identity" not in self.identity_data
or self.identity_data["identity"] is None
):
logger.warning("Identity validation failed: missing 'identity' field")
raise HTTPException(status_code=400, detail="Invalid identity data")
identity = self.identity_data["identity"]
if "type" not in identity:
logger.warning("Identity validation failed: missing 'type' field")
raise HTTPException(status_code=400, detail="Invalid identity data")
identity_type = identity["type"]
if identity_type == "User":
self._validate_user_fields(identity)
elif identity_type == "System":
self._validate_system_fields(identity)
elif identity_type == "ServiceAccount":
self._validate_service_account_fields(identity)
else:
logger.warning("Identity validation failed: unsupported identity type")
raise HTTPException(status_code=400, detail="Invalid identity data")
# Validate org_id if present and non-empty
org_id = identity.get("org_id")
if org_id is not None and org_id != "":
self._validate_string_field("org_id", org_id)
def _validate_user_fields(self, identity: dict) -> None:
"""Validate required fields for User identity type.
Args:
identity: The identity dict containing user data
Raises:
HTTPException: 400 if required User fields are missing or malformed
"""
if "user" not in identity:
logger.warning(
"Identity validation failed: missing 'user' field for User type"
)
raise HTTPException(status_code=400, detail="Invalid identity data")
user = identity["user"]
if "user_id" not in user:
logger.warning("Identity validation failed: missing 'user_id' in user data")
raise HTTPException(status_code=400, detail="Invalid identity data")
if "username" not in user:
logger.warning(
"Identity validation failed: missing 'username' in user data"
)
raise HTTPException(status_code=400, detail="Invalid identity data")
self._validate_string_field("user_id", user["user_id"])
self._validate_string_field("username", user["username"])
def _validate_system_fields(self, identity: dict) -> None:
"""Validate required fields for System identity type.
Args:
identity: The identity dict containing system data
Raises:
HTTPException: 400 if required System fields are missing or malformed
"""
if "system" not in identity:
logger.warning(
"Identity validation failed: missing 'system' field for System type"
)
raise HTTPException(status_code=400, detail="Invalid identity data")
system = identity["system"]
if "cn" not in system:
logger.warning("Identity validation failed: missing 'cn' in system data")
raise HTTPException(status_code=400, detail="Invalid identity data")
self._validate_string_field("cn", system["cn"])
# org_id is the required organizational identifier for System identities
# (per the canonical Red Hat identity spec). account_number is optional:
# no-cost RHEL developer subscriptions send an empty account_number.
org_id = identity.get("org_id")
if org_id is None or org_id == "":
logger.warning(
"Identity validation failed: missing 'org_id' for System type"
)
raise HTTPException(status_code=400, detail="Invalid identity data")
# account_number is optional, but when present and non-empty it must be
# a well-formed string.
account_number = identity.get("account_number")
if account_number is not None and account_number != "":
self._validate_string_field("account_number", account_number)
def _validate_service_account_fields(self, identity: dict) -> None:
"""Validate required fields for ServiceAccount identity type.
Args:
identity: The identity dict containing service_account data
Raises:
HTTPException: 400 if required ServiceAccount fields are missing or malformed
"""
if "service_account" not in identity:
logger.warning(
"Identity validation failed: missing 'service_account' field "
"for ServiceAccount type"
)
raise HTTPException(status_code=400, detail="Invalid identity data")
service_account = identity["service_account"]
for field in ("client_id", "username"):
if field not in service_account:
logger.warning(
"Identity validation failed: missing '%s' in service_account data",
field,
)
raise HTTPException(status_code=400, detail="Invalid identity data")
self._validate_string_field(field, service_account[field])
def _validate_string_field(
self, field_name: str, value: Any, max_length: int = 256
) -> None:
"""Validate that a field value is a well-formed string.
Args:
field_name: Name of the field being validated (for error messages)
value: The value to validate
max_length: Maximum allowed string length
Raises:
HTTPException: 400 if validation fails
"""
if not isinstance(value, str):
logger.warning(
"Identity validation failed: %s must be a string, got %s",
field_name,
type(value).__name__,
)
raise HTTPException(status_code=400, detail="Invalid identity data")
if not value.strip():
logger.warning(
"Identity validation failed: %s must not be empty",
field_name,
)
raise HTTPException(status_code=400, detail="Invalid identity data")
if len(value) > max_length:
logger.warning(
"Identity validation failed: %s exceeds maximum length of %d",
field_name,
max_length,
)
raise HTTPException(status_code=400, detail="Invalid identity data")
if any(ord(c) < 32 or ord(c) == 127 for c in value):
logger.warning(
"Identity validation failed: %s contains control characters",
field_name,
)
raise HTTPException(status_code=400, detail="Invalid identity data")
def _get_identity_type(self) -> str:
"""Get the identity type (User, System, or ServiceAccount).
Returns:
Identity type string
"""
return self.identity_data["identity"]["type"]
def get_user_id(self) -> str:
"""Extract user ID based on identity type.
Returns:
User ID (user.user_id for User type, system.cn for System type,
service_account.client_id for ServiceAccount type)
"""
identity = self.identity_data["identity"]
identity_type = self._get_identity_type()
if identity_type == "User":
return identity["user"]["user_id"]
if identity_type == "ServiceAccount":
return identity["service_account"]["client_id"]
return identity["system"]["cn"]
def get_username(self) -> str:
"""Extract username based on identity type.
For System identities the account_number is preferred when present and
non-empty, falling back to the system common name (cn). No-cost RHEL
developer subscriptions send an empty account_number, so the cn provides
a stable non-empty identifier in that case.
Returns:
Username (user.username for User type;
service_account.username for ServiceAccount type;
account_number or system.cn for System type)
"""
identity = self.identity_data["identity"]
identity_type = self._get_identity_type()
if identity_type == "User":
return identity["user"]["username"]
if identity_type == "ServiceAccount":
return identity["service_account"]["username"]
account_number = identity.get("account_number")
if account_number:
return account_number
return identity["system"]["cn"]
def get_org_id(self) -> str:
"""Extract organization ID from identity data.
Returns:
Organization ID string, or empty string if not present
"""
return self.identity_data["identity"].get("org_id", "")
def get_system_id(self) -> str:
"""Extract system ID from identity data.
Returns:
System ID string for System identities, or empty string for User identities
"""
if self._get_identity_type() == "System":
return self.identity_data["identity"]["system"]["cn"]
return ""
def has_entitlement(self, service: str) -> bool:
"""Check if user has a specific service entitlement.
Args:
service: Service name to check (e.g., "rhel", "ansible", "openshift")
Returns:
True if user has the entitlement and is_entitled is True
"""
entitlements = self.identity_data.get("entitlements", {})
service_entitlement = entitlements.get(service, {})
return service_entitlement.get("is_entitled", False)
def has_entitlements(self, services: list[str]) -> bool:
"""Check if user has ALL specified service entitlements.
Args:
services: List of service names to check
Returns:
True if user has ALL entitlements in the list
"""
return all(self.has_entitlement(service) for service in services)
def validate_entitlements(self) -> None:
"""Validate required entitlements based on configuration.
Raises:
HTTPException: 403 if required entitlements are missing
"""
if not self.required_entitlements:
return # No validation required
missing = [s for s in self.required_entitlements if not self.has_entitlement(s)]
if missing:
logger.warning(
"Entitlement validation failed: missing required entitlements: %s",
", ".join(missing),
)
raise HTTPException(
status_code=403,
detail="Insufficient entitlements",
)
class RHIdentityAuthDependency(AuthInterface): # pylint: disable=too-few-public-methods
"""Red Hat Identity header authentication dependency for FastAPI.
Authenticates requests using the x-rh-identity header with base64-encoded JSON.
Supports User, System, and ServiceAccount identity types with optional
entitlement validation.
"""
def __init__(
self,
required_entitlements: Optional[list[str]] = None,
virtual_path: str = DEFAULT_VIRTUAL_PATH,
max_header_size: int = DEFAULT_RH_IDENTITY_MAX_HEADER_SIZE,
) -> None:
"""Initialize RH Identity authentication dependency.
Args:
required_entitlements: Services to require (ALL must be present)
virtual_path: Virtual path for authorization checks
max_header_size: Maximum allowed size in bytes for the base64-encoded
x-rh-identity header. Headers exceeding this size are rejected
before decoding.
"""
self.required_entitlements = required_entitlements
self.virtual_path = virtual_path
self.max_header_size = max_header_size
self.skip_userid_check = False
async def __call__(self, request: Request) -> AuthTuple:
"""Validate FastAPI request for RH Identity authentication.
Args:
request: The FastAPI request object
Returns:
AuthTuple: (user_id, username, skip_userid_check, token)
Raises:
HTTPException:
- 401: Missing x-rh-identity header
- 400: Invalid base64, invalid JSON, or missing required fields
- 403: Missing required entitlements
"""
# Extract header
identity_header = request.headers.get("x-rh-identity")
if not identity_header:
# Skip auth for health probes when configured
if request.url.path.endswith(("/readiness", "/liveness")):
if configuration.authentication_configuration.skip_for_health_probes:
return NO_AUTH_TUPLE
# Skip auth for metrics endpoint when configured
if request.url.path.endswith("/metrics"):
if configuration.authentication_configuration.skip_for_metrics:
return NO_AUTH_TUPLE
logger.warning("Missing x-rh-identity header")
raise HTTPException(status_code=401, detail="Missing x-rh-identity header")
# Enforce header size limit before decoding
if len(identity_header) > self.max_header_size:
logger.warning(
"x-rh-identity header size %d exceeds maximum allowed size %d",
len(identity_header),
self.max_header_size,
)
raise HTTPException(
status_code=400,
detail="x-rh-identity header exceeds maximum allowed size",
)
# Decode base64
try:
decoded_bytes = base64.b64decode(identity_header, validate=True)
decoded_str = decoded_bytes.decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
logger.warning("Invalid base64 in x-rh-identity header: %s", exc)
raise HTTPException(
status_code=400,
detail="Invalid base64 encoding in x-rh-identity header",
) from exc
# Parse JSON
try:
identity_data = json.loads(decoded_str)
except json.JSONDecodeError as exc:
logger.warning("Invalid JSON in x-rh-identity header: %s", exc)
raise HTTPException(
status_code=400, detail="Invalid JSON in x-rh-identity header"
) from exc
# Extract and validate identity
rh_identity = RHIdentityData(
identity_data,
required_entitlements=self.required_entitlements,
)
# Validate entitlements if configured
rh_identity.validate_entitlements()
# Store identity data in request.state for downstream access
request.state.rh_identity_data = rh_identity
# Extract user data
user_id = rh_identity.get_user_id()
username = rh_identity.get_username()
logger.info(
"RH Identity authenticated: request_id=%s, path=%s, org_id=%s, system_id=%s",
_get_request_id(request),
request.url.path,
rh_identity.get_org_id(),
rh_identity.get_system_id(),
)
return user_id, username, self.skip_userid_check, NO_USER_TOKEN