Skip to content

Commit bf9b8a7

Browse files
authored
Merge pull request lightspeed-core#1851 from major/fix/rh-identity-developer-subscription
RSPEED-3167: accept System identities with empty account_number
2 parents 71cd53c + 4f271c2 commit bf9b8a7

3 files changed

Lines changed: 152 additions & 14 deletions

File tree

docs/auth/rh-identity.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,13 @@ directly (e.g., Insights client, subscription-manager).
114114

115115
**Identity extraction:**
116116
- `user_id`: From `identity.system.cn` (certificate Common Name)
117-
- `username`: From `identity.account_number`
117+
- `username`: From `identity.account_number` when present and non-empty,
118+
otherwise falls back to `identity.system.cn`
119+
120+
`org_id` is the required organizational identifier for System identities.
121+
`account_number` is optional: no-cost RHEL developer subscriptions send an
122+
empty `account_number` with a populated `org_id`. A System identity is rejected
123+
only when `org_id` or `system.cn` is empty or absent.
118124

119125
**Header structure:**
120126
```json
@@ -134,6 +140,22 @@ directly (e.g., Insights client, subscription-manager).
134140
}
135141
```
136142

143+
A developer-subscription System identity omits the account number:
144+
```json
145+
{
146+
"identity": {
147+
"account_number": "",
148+
"org_id": "18939564",
149+
"type": "System",
150+
"auth_type": "cert-auth",
151+
"system": {
152+
"cn": "14b75b86-6f99-411d-b41d-f400268b5807",
153+
"cert_type": "system"
154+
}
155+
}
156+
}
157+
```
158+
137159
**Available System fields:**
138160

139161
| Field | Type | Description |
@@ -147,8 +169,8 @@ Both identity types share these top-level fields:
147169

148170
| Field | Type | Description |
149171
|-------|------|-------------|
150-
| `account_number` | string | Red Hat account number |
151-
| `org_id` | string | Organization ID |
172+
| `account_number` | string | Red Hat account number (optional for System identities; may be empty for developer subscriptions) |
173+
| `org_id` | string | Organization ID (required for System identities) |
152174
| `type` | string | Identity type: "User" or "System" |
153175

154176
## Entitlements
@@ -269,7 +291,7 @@ curl http://localhost:8080/v1/query \
269291
| 400 | Missing `username` in user | `{"detail": "Missing 'username' in user data"}` |
270292
| 400 | Missing `system` for System type | `{"detail": "Missing 'system' field for System type"}` |
271293
| 400 | Missing `cn` in system | `{"detail": "Missing 'cn' in system data"}` |
272-
| 400 | Missing `account_number` for System | `{"detail": "Missing 'account_number' for System type"}` |
294+
| 400 | Missing `org_id` for System | `{"detail": "Missing 'org_id' for System type"}` |
273295
| 400 | Unsupported identity type | `{"detail": "Unsupported identity type: X"}` |
274296
| 403 | Missing required entitlements | `{"detail": "Missing required entitlement: rhel"}` |
275297

src/authentication/rh_identity.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,23 @@ def _validate_system_fields(self, identity: dict) -> None:
135135
if "cn" not in system:
136136
logger.warning("Identity validation failed: missing 'cn' in system data")
137137
raise HTTPException(status_code=400, detail="Invalid identity data")
138-
if "account_number" not in identity:
138+
self._validate_string_field("cn", system["cn"])
139+
140+
# org_id is the required organizational identifier for System identities
141+
# (per the canonical Red Hat identity spec). account_number is optional:
142+
# no-cost RHEL developer subscriptions send an empty account_number.
143+
org_id = identity.get("org_id")
144+
if org_id is None or org_id == "":
139145
logger.warning(
140-
"Identity validation failed: "
141-
"missing 'account_number' for System type"
146+
"Identity validation failed: missing 'org_id' for System type"
142147
)
143148
raise HTTPException(status_code=400, detail="Invalid identity data")
144-
self._validate_string_field("cn", system["cn"])
145-
self._validate_string_field("account_number", identity["account_number"])
149+
150+
# account_number is optional, but when present and non-empty it must be
151+
# a well-formed string.
152+
account_number = identity.get("account_number")
153+
if account_number is not None and account_number != "":
154+
self._validate_string_field("account_number", account_number)
146155

147156
def _validate_string_field(
148157
self, field_name: str, value: Any, max_length: int = 256
@@ -207,14 +216,24 @@ def get_user_id(self) -> str:
207216
def get_username(self) -> str:
208217
"""Extract username based on identity type.
209218
219+
For System identities the account_number is preferred when present and
220+
non-empty, falling back to the system common name (cn). No-cost RHEL
221+
developer subscriptions send an empty account_number, so the cn provides
222+
a stable non-empty identifier in that case.
223+
210224
Returns:
211-
Username (user.username for User type, account_number for System type)
225+
Username (user.username for User type; account_number or system.cn
226+
for System type)
212227
"""
213228
identity = self.identity_data["identity"]
214229

215230
if self._get_identity_type() == "User":
216231
return identity["user"]["username"]
217-
return identity["account_number"]
232+
233+
account_number = identity.get("account_number")
234+
if account_number:
235+
return account_number
236+
return identity["system"]["cn"]
218237

219238
def get_org_id(self) -> str:
220239
"""Extract organization ID from identity data.

tests/unit/authentication/test_rh_identity.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,22 @@ def test_validate_entitlements(
282282
{"identity": {"type": "System", "org_id": "123", "system": {}}},
283283
"Invalid identity data",
284284
),
285+
# System identity without org_id is rejected (org_id is required).
285286
(
286287
{
287288
"identity": {
288289
"type": "System",
289-
"org_id": "123",
290+
"system": {"cn": "test"},
291+
}
292+
},
293+
"Invalid identity data",
294+
),
295+
# System identity with empty org_id is rejected.
296+
(
297+
{
298+
"identity": {
299+
"type": "System",
300+
"org_id": "",
290301
"system": {"cn": "test"},
291302
}
292303
},
@@ -719,9 +730,9 @@ def test_user_non_string_types_rejected(
719730
(("system", "cn"), True),
720731
(("system", "cn"), []),
721732
(("system", "cn"), {}),
722-
(("account_number",), None),
733+
# account_number is optional: None/absent is allowed, but a present,
734+
# non-empty, non-string value is still rejected.
723735
(("account_number",), 12345),
724-
(("account_number",), False),
725736
(("account_number",), []),
726737
(("account_number",), {}),
727738
],
@@ -826,3 +837,89 @@ def test_valid_user_data_still_passes(self, user_identity_data: dict) -> None:
826837
def test_valid_system_data_still_passes(self, system_identity_data: dict) -> None:
827838
"""Regression: valid System identity data passes validation."""
828839
RHIdentityData(system_identity_data)
840+
841+
@pytest.mark.parametrize("account_number", ["", None])
842+
def test_system_empty_or_absent_account_number_accepted(
843+
self, system_identity_data: dict, account_number: Optional[str]
844+
) -> None:
845+
"""Accept System identity with empty or absent account_number.
846+
847+
No-cost RHEL developer subscriptions send an empty account_number with a
848+
populated org_id. These must authenticate successfully, falling back to
849+
the system cn as the username.
850+
"""
851+
identity = system_identity_data["identity"]
852+
if account_number is None:
853+
identity.pop("account_number", None)
854+
else:
855+
identity["account_number"] = account_number
856+
857+
rh_identity = RHIdentityData(system_identity_data)
858+
# username falls back to the system cn when account_number is empty/absent
859+
assert rh_identity.get_username() == identity["system"]["cn"]
860+
assert rh_identity.get_user_id() == identity["system"]["cn"]
861+
862+
def test_system_developer_subscription_identity_accepted(self) -> None:
863+
"""Accept a real developer-subscription System identity end-to-end.
864+
865+
Mirrors the cert-auth payload sent by no-cost RHEL developer
866+
subscriptions: empty account_number, populated org_id, valid system cn.
867+
"""
868+
identity_data = {
869+
"identity": {
870+
"system": {
871+
"cert_type": "system",
872+
"cn": "14b75b86-6f99-411d-b41d-f400268b5807",
873+
},
874+
"auth_type": "cert-auth",
875+
"account_number": "",
876+
"type": "System",
877+
"org_id": "18939564",
878+
"internal": {
879+
"cross_access": False,
880+
"auth_time": 0,
881+
"org_id": "18939564",
882+
},
883+
}
884+
}
885+
886+
rh_identity = RHIdentityData(identity_data)
887+
assert rh_identity.get_username() == "14b75b86-6f99-411d-b41d-f400268b5807"
888+
assert rh_identity.get_user_id() == "14b75b86-6f99-411d-b41d-f400268b5807"
889+
assert rh_identity.get_org_id() == "18939564"
890+
891+
def test_system_present_account_number_unchanged(
892+
self, system_identity_data: dict
893+
) -> None:
894+
"""Regression: System with a present, non-empty account_number is unchanged.
895+
896+
get_username() must still return the account_number when it is present
897+
and non-empty.
898+
"""
899+
rh_identity = RHIdentityData(system_identity_data)
900+
assert rh_identity.get_username() == "123"
901+
902+
@pytest.mark.parametrize("org_id", ["", None])
903+
def test_system_missing_org_id_rejected(
904+
self, system_identity_data: dict, org_id: Optional[str]
905+
) -> None:
906+
"""Reject System identity when org_id is empty or absent.
907+
908+
org_id is the required organizational identifier for System identities.
909+
"""
910+
identity = system_identity_data["identity"]
911+
if org_id is None:
912+
identity.pop("org_id", None)
913+
else:
914+
identity["org_id"] = org_id
915+
916+
with pytest.raises(HTTPException) as exc_info:
917+
RHIdentityData(system_identity_data)
918+
assert exc_info.value.status_code == 400
919+
920+
def test_system_empty_cn_rejected(self, system_identity_data: dict) -> None:
921+
"""Reject System identity when system cn is empty."""
922+
system_identity_data["identity"]["system"]["cn"] = ""
923+
with pytest.raises(HTTPException) as exc_info:
924+
RHIdentityData(system_identity_data)
925+
assert exc_info.value.status_code == 400

0 commit comments

Comments
 (0)