Skip to content

Commit c5145a4

Browse files
committed
test(auth): add OIDC e2e tests for RBAC, scopes, and client credentials
Add 10 end-to-end tests covering OIDC authentication against a live K3s cluster with Keycloak: RBAC (5 tests): admin can create providers, user cannot, user can list sandboxes, unauthenticated requests rejected, health probe works without auth. Scopes (4 tests): sandbox-scoped token can list sandboxes but not providers, openshell:all grants full access, no-scopes token denied. Client credentials (1 test): CI token via client_credentials grant. Tests are opt-in via OPENSHELL_E2E_OIDC=1 and OPENSHELL_E2E_OIDC_SCOPES=1 env vars. They derive the Keycloak URL from gateway metadata to match the server's configured issuer. Run with: OPENSHELL_E2E_OIDC=1 OPENSHELL_E2E_OIDC_SCOPES=1 \ PYTHONPATH=python uv run pytest e2e/python/oidc/ -v
1 parent 56f901b commit c5145a4

2 files changed

Lines changed: 300 additions & 0 deletions

File tree

‎e2e/python/oidc/conftest.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""OIDC e2e test fixtures.
5+
6+
Overrides the parent conftest's session fixtures that assume unauthenticated
7+
gRPC access, since the OIDC-enabled gateway requires Bearer tokens.
8+
"""
9+
10+
import pytest
11+
12+
13+
@pytest.fixture(scope="session")
14+
def sandbox_client():
15+
"""Stub — OIDC tests manage their own authenticated gRPC connections."""
16+
pytest.skip("OIDC tests do not use the shared sandbox_client fixture")
17+
18+
19+
@pytest.fixture(scope="session", autouse=True)
20+
def ensure_sandbox_persistence_ready():
21+
"""No-op — OIDC tests skip the unauthenticated persistence check."""

‎e2e/python/oidc/oidc_auth_test.py‎

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""End-to-end tests for OIDC authentication, RBAC, and scope enforcement.
5+
6+
These tests require:
7+
- A running K3s cluster with OIDC enabled (OPENSHELL_OIDC_ISSUER set)
8+
- A running Keycloak instance with the openshell realm
9+
- The cluster started with OPENSHELL_OIDC_SCOPES_CLAIM=scope
10+
11+
Skip condition: set OPENSHELL_E2E_OIDC=1 to enable these tests.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import contextlib
17+
import json
18+
import os
19+
import urllib.parse
20+
import urllib.request
21+
from pathlib import Path
22+
23+
import grpc
24+
import pytest
25+
26+
from openshell._proto import datamodel_pb2, openshell_pb2, openshell_pb2_grpc
27+
28+
KEYCLOAK_REALM = "openshell"
29+
30+
31+
def _xdg_config_home() -> Path:
32+
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
33+
34+
35+
def _keycloak_url() -> str:
36+
"""Derive the Keycloak URL from the gateway's stored OIDC issuer.
37+
38+
The server validates the issuer claim in JWTs, so the token must be
39+
requested from the same base URL the server was configured with
40+
(typically the host IP, not localhost).
41+
"""
42+
if url := os.environ.get("OPENSHELL_KEYCLOAK_URL"):
43+
return url
44+
cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell")
45+
metadata_path = (
46+
_xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json"
47+
)
48+
if metadata_path.exists():
49+
metadata = json.loads(metadata_path.read_text())
50+
issuer = metadata.get("oidc_issuer", "")
51+
if issuer:
52+
# issuer is like "http://192.168.4.172:8180/realms/openshell"
53+
# extract base URL before /realms/
54+
idx = issuer.find("/realms/")
55+
if idx > 0:
56+
return issuer[:idx]
57+
return "http://localhost:8180"
58+
59+
60+
TOKEN_ENDPOINT = (
61+
f"{_keycloak_url()}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/token"
62+
)
63+
64+
pytestmark = pytest.mark.skipif(
65+
os.environ.get("OPENSHELL_E2E_OIDC") != "1",
66+
reason="OIDC e2e tests disabled (set OPENSHELL_E2E_OIDC=1)",
67+
)
68+
69+
70+
def _gateway_endpoint() -> tuple[str, bool]:
71+
"""Read the active gateway endpoint from metadata."""
72+
cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell")
73+
metadata_path = (
74+
_xdg_config_home() / "openshell" / "gateways" / cluster_name / "metadata.json"
75+
)
76+
metadata = json.loads(metadata_path.read_text())
77+
endpoint = metadata["gateway_endpoint"]
78+
is_tls = endpoint.startswith("https://")
79+
return endpoint, is_tls
80+
81+
82+
def _mtls_dir() -> Path:
83+
cluster_name = os.environ.get("OPENSHELL_GATEWAY", "openshell")
84+
return _xdg_config_home() / "openshell" / "gateways" / cluster_name / "mtls"
85+
86+
87+
def _token_request(data: dict[str, str]) -> str:
88+
"""POST to the Keycloak token endpoint and return the access token."""
89+
encoded = urllib.parse.urlencode(data).encode()
90+
req = urllib.request.Request(TOKEN_ENDPOINT, data=encoded)
91+
with urllib.request.urlopen(req, timeout=10) as resp:
92+
body = json.loads(resp.read())
93+
return body["access_token"]
94+
95+
96+
def _get_token(
97+
username: str,
98+
password: str,
99+
*,
100+
client_id: str = "openshell-cli",
101+
scopes: str | None = None,
102+
) -> str:
103+
"""Get an access token from Keycloak via password grant."""
104+
data = {
105+
"grant_type": "password",
106+
"client_id": client_id,
107+
"username": username,
108+
"password": password,
109+
}
110+
if scopes:
111+
data["scope"] = scopes
112+
return _token_request(data)
113+
114+
115+
def _get_ci_token(
116+
*,
117+
client_id: str = "openshell-ci",
118+
client_secret: str = "ci-test-secret",
119+
) -> str:
120+
"""Get an access token via client credentials grant."""
121+
return _token_request(
122+
{
123+
"grant_type": "client_credentials",
124+
"client_id": client_id,
125+
"client_secret": client_secret,
126+
}
127+
)
128+
129+
130+
def _grpc_channel() -> grpc.Channel:
131+
"""Create a gRPC channel to the gateway with mTLS transport."""
132+
endpoint, is_tls = _gateway_endpoint()
133+
parsed = urllib.parse.urlparse(endpoint)
134+
host = parsed.hostname or "127.0.0.1"
135+
port = parsed.port or (443 if is_tls else 80)
136+
target = f"{host}:{port}"
137+
138+
if is_tls:
139+
mtls = _mtls_dir()
140+
ca_cert = (mtls / "ca.crt").read_bytes()
141+
client_cert = (mtls / "tls.crt").read_bytes()
142+
client_key = (mtls / "tls.key").read_bytes()
143+
creds = grpc.ssl_channel_credentials(
144+
root_certificates=ca_cert,
145+
private_key=client_key,
146+
certificate_chain=client_cert,
147+
)
148+
return grpc.secure_channel(target, creds)
149+
return grpc.insecure_channel(target)
150+
151+
152+
def _stub_with_token(token: str) -> openshell_pb2_grpc.OpenShellStub:
153+
"""Create a gRPC stub that injects a Bearer token."""
154+
channel = _grpc_channel()
155+
return openshell_pb2_grpc.OpenShellStub(channel), [
156+
("authorization", f"Bearer {token}")
157+
]
158+
159+
160+
# ── RBAC Tests ────────────────────────────────────────────────────────
161+
162+
163+
class TestRbac:
164+
"""Test role-based access control."""
165+
166+
def test_admin_can_create_provider(self) -> None:
167+
token = _get_token("admin@test", "admin", scopes="openid openshell:all")
168+
stub, metadata = _stub_with_token(token)
169+
req = openshell_pb2.CreateProviderRequest(
170+
provider=datamodel_pb2.Provider(
171+
name="e2e-oidc-admin-test",
172+
type="claude",
173+
credentials={"API_KEY": "test-value"},
174+
)
175+
)
176+
try:
177+
stub.CreateProvider(req, metadata=metadata)
178+
except grpc.RpcError as e:
179+
if e.code() == grpc.StatusCode.ALREADY_EXISTS:
180+
pass # fine, provider exists from a previous run
181+
else:
182+
raise
183+
finally:
184+
with contextlib.suppress(grpc.RpcError):
185+
stub.DeleteProvider(
186+
openshell_pb2.DeleteProviderRequest(name="e2e-oidc-admin-test"),
187+
metadata=metadata,
188+
)
189+
190+
def test_user_cannot_create_provider(self) -> None:
191+
token = _get_token("user@test", "user", scopes="openid openshell:all")
192+
stub, metadata = _stub_with_token(token)
193+
req = openshell_pb2.CreateProviderRequest(
194+
provider=datamodel_pb2.Provider(
195+
name="e2e-oidc-user-blocked",
196+
type="claude",
197+
credentials={"API_KEY": "test-value"},
198+
)
199+
)
200+
with pytest.raises(grpc.RpcError) as exc_info:
201+
stub.CreateProvider(req, metadata=metadata)
202+
assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED
203+
assert "openshell-admin" in exc_info.value.details()
204+
205+
def test_user_can_list_sandboxes(self) -> None:
206+
token = _get_token("user@test", "user", scopes="openid openshell:all")
207+
stub, metadata = _stub_with_token(token)
208+
stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata)
209+
210+
def test_unauthenticated_request_rejected(self) -> None:
211+
channel = _grpc_channel()
212+
stub = openshell_pb2_grpc.OpenShellStub(channel)
213+
with pytest.raises(grpc.RpcError) as exc_info:
214+
stub.ListSandboxes(openshell_pb2.ListSandboxesRequest())
215+
assert exc_info.value.code() == grpc.StatusCode.UNAUTHENTICATED
216+
217+
def test_health_does_not_require_auth(self) -> None:
218+
channel = _grpc_channel()
219+
stub = openshell_pb2_grpc.OpenShellStub(channel)
220+
resp = stub.Health(openshell_pb2.HealthRequest())
221+
assert resp.status == openshell_pb2.SERVICE_STATUS_HEALTHY
222+
223+
224+
# ── Scope Enforcement Tests ──────────────────────────────────────────
225+
226+
227+
class TestScopes:
228+
"""Test scope-based fine-grained permissions.
229+
230+
These tests require the server to be started with
231+
OPENSHELL_OIDC_SCOPES_CLAIM=scope.
232+
"""
233+
234+
pytestmark = pytest.mark.skipif(
235+
os.environ.get("OPENSHELL_E2E_OIDC_SCOPES") != "1",
236+
reason="Scope e2e tests disabled (set OPENSHELL_E2E_OIDC_SCOPES=1)",
237+
)
238+
239+
def test_sandbox_scoped_token_can_list_sandboxes(self) -> None:
240+
token = _get_token(
241+
"admin@test", "admin", scopes="openid sandbox:read sandbox:write"
242+
)
243+
stub, metadata = _stub_with_token(token)
244+
stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata)
245+
246+
def test_sandbox_scoped_token_cannot_list_providers(self) -> None:
247+
token = _get_token(
248+
"admin@test", "admin", scopes="openid sandbox:read sandbox:write"
249+
)
250+
stub, metadata = _stub_with_token(token)
251+
with pytest.raises(grpc.RpcError) as exc_info:
252+
stub.ListProviders(openshell_pb2.ListProvidersRequest(), metadata=metadata)
253+
assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED
254+
assert "provider:read" in exc_info.value.details()
255+
256+
def test_openshell_all_grants_full_access(self) -> None:
257+
token = _get_token("admin@test", "admin", scopes="openid openshell:all")
258+
stub, metadata = _stub_with_token(token)
259+
stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata)
260+
stub.ListProviders(openshell_pb2.ListProvidersRequest(), metadata=metadata)
261+
262+
def test_no_openshell_scopes_denied(self) -> None:
263+
token = _get_token("admin@test", "admin")
264+
stub, metadata = _stub_with_token(token)
265+
with pytest.raises(grpc.RpcError) as exc_info:
266+
stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata)
267+
assert exc_info.value.code() == grpc.StatusCode.PERMISSION_DENIED
268+
269+
270+
# ── Client Credentials Tests ─────────────────────────────────────────
271+
272+
273+
class TestClientCredentials:
274+
"""Test CI/automation client credentials flow."""
275+
276+
def test_ci_token_can_list_sandboxes(self) -> None:
277+
token = _get_ci_token()
278+
stub, metadata = _stub_with_token(token)
279+
stub.ListSandboxes(openshell_pb2.ListSandboxesRequest(), metadata=metadata)

0 commit comments

Comments
 (0)