Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 72 additions & 6 deletions docs/auth/rh-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ validates the `x-rh-identity` header provided by Red Hat's authentication proxy.
The `rh-identity` module:
1. Extracts the `x-rh-identity` header from incoming requests
2. Base64 decodes and parses the JSON payload
3. Validates the identity structure based on type (User or System)
3. Validates the identity structure based on type (User, System, or ServiceAccount)
4. Optionally validates service entitlements
5. Extracts user identity for downstream use

Expand Down Expand Up @@ -62,7 +62,7 @@ entitlement validation entirely.

## Identity Types

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

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

### ServiceAccount Identity

OAuth service accounts authenticated via JWT. Used when automated services or
integrations access APIs using client credentials.

**Identity extraction:**
- `user_id`: From `identity.service_account.client_id`
- `username`: From `identity.service_account.username`

**Header structure:**
```json
{
"identity": {
"account_number": "123456",
"org_id": "654321",
"type": "ServiceAccount",
"auth_type": "jwt-auth",
"service_account": {
"client_id": "b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
"username": "service-account-b69eaf9e-e6a6-4f9e-805e-02987daddfbd"
}
},
"entitlements": {
"rhel": {"is_entitled": true, "is_trial": false}
}
}
```

**Available ServiceAccount fields:**

| Field | Type | Description |
|-------|------|-------------|
| `client_id` | string | OAuth client identifier (used as user_id) |
| `username` | string | Service account username |

## Common Identity Fields

Both identity types share these top-level fields:
All identity types share these top-level fields:

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

## Entitlements

Expand Down Expand Up @@ -214,8 +249,8 @@ async def my_endpoint(request: Request):

| Method | Returns | Description |
|--------|---------|-------------|
| `get_user_id()` | `str` | User ID or system CN |
| `get_username()` | `str` | Username or account number |
| `get_user_id()` | `str` | User ID, system CN, or service account client_id |
| `get_username()` | `str` | Username, account number, or service account username |
| `get_org_id()` | `str` | Organization ID |
| `has_entitlement(service)` | `bool` | Check single entitlement |
| `has_entitlements(services)` | `bool` | Check ALL entitlements in list |
Expand Down Expand Up @@ -277,6 +312,34 @@ curl http://localhost:8080/v1/query \
-d '{"query": "Hello"}'
```

### ServiceAccount Identity Example

```bash
# ServiceAccount identity
IDENTITY='{
"identity": {
"account_number": "123456",
"org_id": "654321",
"type": "ServiceAccount",
"auth_type": "jwt-auth",
"service_account": {
"client_id": "b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
"username": "service-account-b69eaf9e-e6a6-4f9e-805e-02987daddfbd"
}
},
"entitlements": {
"rhel": {"is_entitled": true, "is_trial": false}
}
}'

HEADER=$(echo -n "$IDENTITY" | base64)

curl http://localhost:8080/v1/query \
-H "Content-Type: application/json" \
-H "x-rh-identity: $HEADER" \
-d '{"query": "Hello"}'
```

## Error Responses

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

Expand Down
1 change: 1 addition & 0 deletions examples/lightspeed-stack-rh-identity.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# Identity Types Supported:
# - User: Console users authenticated via Red Hat SSO
# - System: Certificate-authenticated RHEL systems
# - ServiceAccount: OAuth service accounts authenticated via JWT
#
# Security Note: This module trusts the x-rh-identity header. Only use
# behind Red Hat's authentication proxy - never expose directly to the internet.
Expand Down
59 changes: 49 additions & 10 deletions src/authentication/rh_identity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Red Hat Identity header authentication for FastAPI endpoints.

This module provides authentication via the x-rh-identity header, supporting both
User and System identity types with optional entitlement validation.
This module provides authentication via the x-rh-identity header, supporting
User, System, and ServiceAccount identity types with optional entitlement
validation.
"""

import base64
Expand Down Expand Up @@ -36,9 +37,10 @@ def _get_request_id(request: Request) -> str:
class RHIdentityData:
"""Extracts and validates Red Hat Identity header data.

Supports two identity types:
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__(
Expand Down Expand Up @@ -82,6 +84,8 @@ def _validate_structure(self) -> None:
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")
Expand Down Expand Up @@ -153,6 +157,31 @@ def _validate_system_fields(self, identity: dict) -> None:
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:
Expand Down Expand Up @@ -194,7 +223,7 @@ def _validate_string_field(
raise HTTPException(status_code=400, detail="Invalid identity data")

def _get_identity_type(self) -> str:
"""Get the identity type (User or System).
"""Get the identity type (User, System, or ServiceAccount).

Returns:
Identity type string
Expand All @@ -205,12 +234,16 @@ 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)
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 self._get_identity_type() == "User":
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:
Expand All @@ -222,14 +255,19 @@ def get_username(self) -> str:
a stable non-empty identifier in that case.

Returns:
Username (user.username for User type; account_number or system.cn
for System type)
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 self._get_identity_type() == "User":
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
Expand Down Expand Up @@ -302,7 +340,8 @@ class RHIdentityAuthDependency(AuthInterface): # pylint: disable=too-few-public
"""Red Hat Identity header authentication dependency for FastAPI.

Authenticates requests using the x-rh-identity header with base64-encoded JSON.
Supports both User and System identity types with optional entitlement validation.
Supports User, System, and ServiceAccount identity types with optional
entitlement validation.
"""

def __init__(
Expand Down
41 changes: 41 additions & 0 deletions tests/integration/test_rh_identity_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,36 @@ def system_identity_json() -> dict:
}


@pytest.fixture
def service_account_identity_json() -> dict:
"""Fixture providing valid ServiceAccount identity JSON.

Returns:
dict: JSON with keys:
- "identity": contains "account_number", "org_id", "type" set to
"ServiceAccount", "auth_type", and "service_account" with
"client_id", "username", and "user_id".
- "entitlements": contains "rhel" with "is_entitled" and "is_trial"
boolean flags.
"""
return {
"identity": {
"account_number": "789",
"org_id": "987",
"type": "ServiceAccount",
"auth_type": "jwt-auth",
"service_account": {
"client_id": "b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
"username": "service-account-b69eaf9e-e6a6-4f9e-805e-02987daddfbd",
"user_id": "60ce65dc-4b5a-4812-8b65-b48178d92b12",
},
},
"entitlements": {
"rhel": {"is_entitled": True, "is_trial": False},
},
}


def encode_identity(identity_json: dict) -> str:
"""Encode identity JSON to base64.

Expand Down Expand Up @@ -140,3 +170,14 @@ def test_valid_system_identity(

# Should succeed (200) or return empty conversations list
assert response.status_code in [200, 404]

def test_valid_service_account_identity(
self, client: TestClient, service_account_identity_json: dict
) -> None:
"""Test successful request with valid ServiceAccount identity."""
headers = {"x-rh-identity": encode_identity(service_account_identity_json)}

response = client.get("/api/v1/conversations", headers=headers)

# Should succeed (200) or return empty conversations list
assert response.status_code in [200, 404]
Loading
Loading