Skip to content

Commit 79f4d0f

Browse files
authored
Merge pull request #1942 from jrobertboos/lcore-2503
LCORE-2503: Added ServiceAccount for rh_identity
2 parents a0670cc + b378ec5 commit 79f4d0f

5 files changed

Lines changed: 299 additions & 19 deletions

File tree

docs/auth/rh-identity.md

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ validates the `x-rh-identity` header provided by Red Hat's authentication proxy.
99
The `rh-identity` module:
1010
1. Extracts the `x-rh-identity` header from incoming requests
1111
2. Base64 decodes and parses the JSON payload
12-
3. Validates the identity structure based on type (User or System)
12+
3. Validates the identity structure based on type (User, System, or ServiceAccount)
1313
4. Optionally validates service entitlements
1414
5. Extracts user identity for downstream use
1515

@@ -62,7 +62,7 @@ entitlement validation entirely.
6262

6363
## Identity Types
6464

65-
The `x-rh-identity` header supports two identity types, each with different
65+
The `x-rh-identity` header supports three identity types, each with different
6666
structure and use cases.
6767

6868
### User Identity
@@ -163,15 +163,50 @@ A developer-subscription System identity omits the account number:
163163
| `cn` | string | Certificate Common Name (system UUID) |
164164
| `cert_type` | string | Certificate type (usually "system") |
165165

166+
### ServiceAccount Identity
167+
168+
OAuth service accounts authenticated via JWT. Used when automated services or
169+
integrations access APIs using client credentials.
170+
171+
**Identity extraction:**
172+
- `user_id`: From `identity.service_account.client_id`
173+
- `username`: From `identity.service_account.username`
174+
175+
**Header structure:**
176+
```json
177+
{
178+
"identity": {
179+
"account_number": "123456",
180+
"org_id": "654321",
181+
"type": "ServiceAccount",
182+
"auth_type": "jwt-auth",
183+
"service_account": {
184+
"client_id": "b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
185+
"username": "service-account-b69eaf9e-e6a6-4f9e-805e-02987daddfbd"
186+
}
187+
},
188+
"entitlements": {
189+
"rhel": {"is_entitled": true, "is_trial": false}
190+
}
191+
}
192+
```
193+
194+
**Available ServiceAccount fields:**
195+
196+
| Field | Type | Description |
197+
|-------|------|-------------|
198+
| `client_id` | string | OAuth client identifier (used as user_id) |
199+
| `username` | string | Service account username |
200+
166201
## Common Identity Fields
167202

168-
Both identity types share these top-level fields:
203+
All identity types share these top-level fields:
169204

170205
| Field | Type | Description |
171206
|-------|------|-------------|
172207
| `account_number` | string | Red Hat account number (optional for System identities; may be empty for developer subscriptions) |
173208
| `org_id` | string | Organization ID (required for System identities) |
174-
| `type` | string | Identity type: "User" or "System" |
209+
| `type` | string | Identity type: "User", "System", or "ServiceAccount" |
175210

176211
## Entitlements
177212

@@ -214,8 +249,8 @@ async def my_endpoint(request: Request):
214249

215250
| Method | Returns | Description |
216251
|--------|---------|-------------|
217-
| `get_user_id()` | `str` | User ID or system CN |
218-
| `get_username()` | `str` | Username or account number |
252+
| `get_user_id()` | `str` | User ID, system CN, or service account client_id |
253+
| `get_username()` | `str` | Username, account number, or service account username |
219254
| `get_org_id()` | `str` | Organization ID |
220255
| `has_entitlement(service)` | `bool` | Check single entitlement |
221256
| `has_entitlements(services)` | `bool` | Check ALL entitlements in list |
@@ -277,6 +312,34 @@ curl http://localhost:8080/v1/query \
277312
-d '{"query": "Hello"}'
278313
```
279314

315+
### ServiceAccount Identity Example
316+
317+
```bash
318+
# ServiceAccount identity
319+
IDENTITY='{
320+
"identity": {
321+
"account_number": "123456",
322+
"org_id": "654321",
323+
"type": "ServiceAccount",
324+
"auth_type": "jwt-auth",
325+
"service_account": {
326+
"client_id": "b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
327+
"username": "service-account-b69eaf9e-e6a6-4f9e-805e-02987daddfbd"
328+
}
329+
},
330+
"entitlements": {
331+
"rhel": {"is_entitled": true, "is_trial": false}
332+
}
333+
}'
334+
335+
HEADER=$(echo -n "$IDENTITY" | base64)
336+
337+
curl http://localhost:8080/v1/query \
338+
-H "Content-Type: application/json" \
339+
-H "x-rh-identity: $HEADER" \
340+
-d '{"query": "Hello"}'
341+
```
342+
280343
## Error Responses
281344

282345
| Status | Condition | Response |
@@ -292,6 +355,9 @@ curl http://localhost:8080/v1/query \
292355
| 400 | Missing `system` for System type | `{"detail": "Missing 'system' field for System type"}` |
293356
| 400 | Missing `cn` in system | `{"detail": "Missing 'cn' in system data"}` |
294357
| 400 | Missing `org_id` for System | `{"detail": "Missing 'org_id' for System type"}` |
358+
| 400 | Missing `service_account` for ServiceAccount type | `{"detail": "Invalid identity data"}` |
359+
| 400 | Missing `client_id` in service_account | `{"detail": "Invalid identity data"}` |
360+
| 400 | Missing `username` in service_account | `{"detail": "Invalid identity data"}` |
295361
| 400 | Unsupported identity type | `{"detail": "Unsupported identity type: X"}` |
296362
| 403 | Missing required entitlements | `{"detail": "Missing required entitlement: rhel"}` |
297363

examples/lightspeed-stack-rh-identity.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# Identity Types Supported:
1010
# - User: Console users authenticated via Red Hat SSO
1111
# - System: Certificate-authenticated RHEL systems
12+
# - ServiceAccount: OAuth service accounts authenticated via JWT
1213
#
1314
# Security Note: This module trusts the x-rh-identity header. Only use
1415
# behind Red Hat's authentication proxy - never expose directly to the internet.

src/authentication/rh_identity.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Red Hat Identity header authentication for FastAPI endpoints.
22
3-
This module provides authentication via the x-rh-identity header, supporting both
4-
User and System identity types with optional entitlement validation.
3+
This module provides authentication via the x-rh-identity header, supporting
4+
User, System, and ServiceAccount identity types with optional entitlement
5+
validation.
56
"""
67

78
import base64
@@ -36,9 +37,10 @@ def _get_request_id(request: Request) -> str:
3637
class RHIdentityData:
3738
"""Extracts and validates Red Hat Identity header data.
3839
39-
Supports two identity types:
40+
Supports three identity types:
4041
- User: Console users with user_id, username, is_org_admin
4142
- System: Certificate-authenticated RHEL systems with cn as identifier
43+
- ServiceAccount: OAuth service accounts with client_id, username, user_id
4244
"""
4345

4446
def __init__(
@@ -82,6 +84,8 @@ def _validate_structure(self) -> None:
8284
self._validate_user_fields(identity)
8385
elif identity_type == "System":
8486
self._validate_system_fields(identity)
87+
elif identity_type == "ServiceAccount":
88+
self._validate_service_account_fields(identity)
8589
else:
8690
logger.warning("Identity validation failed: unsupported identity type")
8791
raise HTTPException(status_code=400, detail="Invalid identity data")
@@ -153,6 +157,31 @@ def _validate_system_fields(self, identity: dict) -> None:
153157
if account_number is not None and account_number != "":
154158
self._validate_string_field("account_number", account_number)
155159

160+
def _validate_service_account_fields(self, identity: dict) -> None:
161+
"""Validate required fields for ServiceAccount identity type.
162+
163+
Args:
164+
identity: The identity dict containing service_account data
165+
166+
Raises:
167+
HTTPException: 400 if required ServiceAccount fields are missing or malformed
168+
"""
169+
if "service_account" not in identity:
170+
logger.warning(
171+
"Identity validation failed: missing 'service_account' field "
172+
"for ServiceAccount type"
173+
)
174+
raise HTTPException(status_code=400, detail="Invalid identity data")
175+
service_account = identity["service_account"]
176+
for field in ("client_id", "username"):
177+
if field not in service_account:
178+
logger.warning(
179+
"Identity validation failed: missing '%s' in service_account data",
180+
field,
181+
)
182+
raise HTTPException(status_code=400, detail="Invalid identity data")
183+
self._validate_string_field(field, service_account[field])
184+
156185
def _validate_string_field(
157186
self, field_name: str, value: Any, max_length: int = 256
158187
) -> None:
@@ -194,7 +223,7 @@ def _validate_string_field(
194223
raise HTTPException(status_code=400, detail="Invalid identity data")
195224

196225
def _get_identity_type(self) -> str:
197-
"""Get the identity type (User or System).
226+
"""Get the identity type (User, System, or ServiceAccount).
198227
199228
Returns:
200229
Identity type string
@@ -205,12 +234,16 @@ def get_user_id(self) -> str:
205234
"""Extract user ID based on identity type.
206235
207236
Returns:
208-
User ID (user.user_id for User type, system.cn for System type)
237+
User ID (user.user_id for User type, system.cn for System type,
238+
service_account.client_id for ServiceAccount type)
209239
"""
210240
identity = self.identity_data["identity"]
241+
identity_type = self._get_identity_type()
211242

212-
if self._get_identity_type() == "User":
243+
if identity_type == "User":
213244
return identity["user"]["user_id"]
245+
if identity_type == "ServiceAccount":
246+
return identity["service_account"]["client_id"]
214247
return identity["system"]["cn"]
215248

216249
def get_username(self) -> str:
@@ -222,14 +255,19 @@ def get_username(self) -> str:
222255
a stable non-empty identifier in that case.
223256
224257
Returns:
225-
Username (user.username for User type; account_number or system.cn
226-
for System type)
258+
Username (user.username for User type;
259+
service_account.username for ServiceAccount type;
260+
account_number or system.cn for System type)
227261
"""
228262
identity = self.identity_data["identity"]
263+
identity_type = self._get_identity_type()
229264

230-
if self._get_identity_type() == "User":
265+
if identity_type == "User":
231266
return identity["user"]["username"]
232267

268+
if identity_type == "ServiceAccount":
269+
return identity["service_account"]["username"]
270+
233271
account_number = identity.get("account_number")
234272
if account_number:
235273
return account_number
@@ -302,7 +340,8 @@ class RHIdentityAuthDependency(AuthInterface): # pylint: disable=too-few-public
302340
"""Red Hat Identity header authentication dependency for FastAPI.
303341
304342
Authenticates requests using the x-rh-identity header with base64-encoded JSON.
305-
Supports both User and System identity types with optional entitlement validation.
343+
Supports User, System, and ServiceAccount identity types with optional
344+
entitlement validation.
306345
"""
307346

308347
def __init__(

tests/integration/test_rh_identity_integration.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,36 @@ def system_identity_json() -> dict:
9393
}
9494

9595

96+
@pytest.fixture
97+
def service_account_identity_json() -> dict:
98+
"""Fixture providing valid ServiceAccount identity JSON.
99+
100+
Returns:
101+
dict: JSON with keys:
102+
- "identity": contains "account_number", "org_id", "type" set to
103+
"ServiceAccount", "auth_type", and "service_account" with
104+
"client_id", "username", and "user_id".
105+
- "entitlements": contains "rhel" with "is_entitled" and "is_trial"
106+
boolean flags.
107+
"""
108+
return {
109+
"identity": {
110+
"account_number": "789",
111+
"org_id": "987",
112+
"type": "ServiceAccount",
113+
"auth_type": "jwt-auth",
114+
"service_account": {
115+
"client_id": "b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
116+
"username": "service-account-b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
117+
"user_id": "60ce65dc-4b5a-4812-8b65-b48178d92b12",
118+
},
119+
},
120+
"entitlements": {
121+
"rhel": {"is_entitled": True, "is_trial": False},
122+
},
123+
}
124+
125+
96126
def encode_identity(identity_json: dict) -> str:
97127
"""Encode identity JSON to base64.
98128
@@ -140,3 +170,14 @@ def test_valid_system_identity(
140170

141171
# Should succeed (200) or return empty conversations list
142172
assert response.status_code in [200, 404]
173+
174+
def test_valid_service_account_identity(
175+
self, client: TestClient, service_account_identity_json: dict
176+
) -> None:
177+
"""Test successful request with valid ServiceAccount identity."""
178+
headers = {"x-rh-identity": encode_identity(service_account_identity_json)}
179+
180+
response = client.get("/api/v1/conversations", headers=headers)
181+
182+
# Should succeed (200) or return empty conversations list
183+
assert response.status_code in [200, 404]

0 commit comments

Comments
 (0)