Skip to content

Commit 21cfe43

Browse files
feat: add llmd auth tests (#709)
* feat: add llmd auth tests * change: use tinyllama oci for llmisvc auth tests * change: address pr feedback * change: split llmisvc auth tests into individual tests
1 parent aba0370 commit 21cfe43

File tree

5 files changed

+274
-2
lines changed

5 files changed

+274
-2
lines changed

tests/model_serving/model_server/llmd/conftest.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
from contextlib import ExitStack
12
from typing import Generator
23

34
import pytest
45
from _pytest.fixtures import FixtureRequest
56
from kubernetes.dynamic import DynamicClient
67
from ocp_resources.llm_inference_service import LLMInferenceService
78
from ocp_resources.namespace import Namespace
9+
from ocp_resources.role import Role
10+
from ocp_resources.role_binding import RoleBinding
811
from ocp_resources.secret import Secret
912
from ocp_resources.service_account import ServiceAccount
1013

1114
from utilities.constants import Timeout, ResourceLimits
12-
from utilities.infra import s3_endpoint_secret
15+
from utilities.infra import s3_endpoint_secret, create_inference_token
16+
from utilities.logger import RedactedString
1317
from utilities.llmd_utils import create_llmisvc
1418
from utilities.llmd_constants import (
1519
ModelStorage,
@@ -186,3 +190,143 @@ def llmd_inference_service_gpu(
186190

187191
with create_llmisvc(**create_kwargs) as llm_service:
188192
yield llm_service
193+
194+
195+
@pytest.fixture(scope="class")
196+
def llmisvc_auth_service_account(
197+
admin_client: DynamicClient,
198+
unprivileged_model_namespace: Namespace,
199+
) -> Generator:
200+
"""Factory fixture to create service accounts for authentication testing."""
201+
with ExitStack() as stack:
202+
203+
def _create_service_account(name: str) -> ServiceAccount:
204+
"""Create a single service account."""
205+
return stack.enter_context(
206+
cm=ServiceAccount(
207+
client=admin_client,
208+
namespace=unprivileged_model_namespace.name,
209+
name=name,
210+
)
211+
)
212+
213+
yield _create_service_account
214+
215+
216+
@pytest.fixture(scope="class")
217+
def llmisvc_auth_view_role(
218+
admin_client: DynamicClient,
219+
) -> Generator:
220+
"""Factory fixture to create view roles for LLMInferenceServices."""
221+
with ExitStack() as stack:
222+
223+
def _create_view_role(llm_service: LLMInferenceService) -> Role:
224+
"""Create a single view role for a given LLMInferenceService."""
225+
return stack.enter_context(
226+
cm=Role(
227+
client=admin_client,
228+
name=f"{llm_service.name}-view",
229+
namespace=llm_service.namespace,
230+
rules=[
231+
{
232+
"apiGroups": [llm_service.api_group],
233+
"resources": ["llminferenceservices"],
234+
"verbs": ["get"],
235+
"resourceNames": [llm_service.name],
236+
},
237+
],
238+
)
239+
)
240+
241+
yield _create_view_role
242+
243+
244+
@pytest.fixture(scope="class")
245+
def llmisvc_auth_role_binding(
246+
admin_client: DynamicClient,
247+
) -> Generator:
248+
"""Factory fixture to create role bindings."""
249+
with ExitStack() as stack:
250+
251+
def _create_role_binding(
252+
service_account: ServiceAccount,
253+
role: Role,
254+
) -> RoleBinding:
255+
"""Create a single role binding."""
256+
return stack.enter_context(
257+
cm=RoleBinding(
258+
client=admin_client,
259+
namespace=service_account.namespace,
260+
name=f"{service_account.name}-view",
261+
role_ref_name=role.name,
262+
role_ref_kind=role.kind,
263+
subjects_kind="ServiceAccount",
264+
subjects_name=service_account.name,
265+
)
266+
)
267+
268+
yield _create_role_binding
269+
270+
271+
@pytest.fixture(scope="class")
272+
def llmisvc_auth_token() -> Generator:
273+
"""Factory fixture to create inference tokens with all required RBAC resources."""
274+
275+
def _create_token(
276+
service_account: ServiceAccount,
277+
llmisvc: LLMInferenceService,
278+
view_role_factory,
279+
role_binding_factory,
280+
) -> str:
281+
"""Create role, role binding, and return an inference token for an existing service account."""
282+
# Create role and role binding (these factories manage their own cleanup via ExitStack)
283+
role = view_role_factory(llm_service=llmisvc)
284+
role_binding_factory(service_account=service_account, role=role)
285+
return RedactedString(value=create_inference_token(model_service_account=service_account))
286+
287+
yield _create_token
288+
289+
290+
@pytest.fixture(scope="class")
291+
def llmisvc_auth(
292+
admin_client: DynamicClient,
293+
unprivileged_model_namespace: Namespace,
294+
llmisvc_auth_service_account,
295+
) -> Generator:
296+
"""Factory fixture to create LLMInferenceService instances for authentication testing."""
297+
with ExitStack() as stack:
298+
299+
def _create_llmd_auth_service(
300+
service_name: str,
301+
service_account_name: str,
302+
storage_uri: str = ModelStorage.TINYLLAMA_OCI,
303+
container_image: str = ContainerImages.VLLM_CPU,
304+
container_resources: dict | None = None,
305+
) -> tuple[LLMInferenceService, ServiceAccount]:
306+
"""Create a single LLMInferenceService instance with its service account."""
307+
if container_resources is None:
308+
container_resources = {
309+
"limits": {"cpu": "1", "memory": "10Gi"},
310+
"requests": {"cpu": "100m", "memory": "8Gi"},
311+
}
312+
313+
# Create the service account first
314+
sa = llmisvc_auth_service_account(name=service_account_name)
315+
316+
create_kwargs = {
317+
"client": admin_client,
318+
"name": service_name,
319+
"namespace": unprivileged_model_namespace.name,
320+
"storage_uri": storage_uri,
321+
"container_image": container_image,
322+
"container_resources": container_resources,
323+
"service_account": service_account_name,
324+
"wait": True,
325+
"timeout": Timeout.TIMEOUT_15MIN,
326+
"enable_auth": True,
327+
}
328+
329+
llm_service = stack.enter_context(cm=create_llmisvc(**create_kwargs))
330+
return (llm_service, sa)
331+
332+
yield _create_llmd_auth_service
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import pytest
2+
3+
from tests.model_serving.model_server.llmd.utils import (
4+
verify_llm_service_status,
5+
verify_gateway_status,
6+
)
7+
from utilities.constants import Protocols
8+
from utilities.llmd_utils import verify_inference_response_llmd
9+
from utilities.manifests.tinyllama import TINYLLAMA_INFERENCE_CONFIG
10+
11+
pytestmark = [
12+
pytest.mark.llmd_cpu,
13+
]
14+
15+
16+
@pytest.mark.parametrize(
17+
"unprivileged_model_namespace",
18+
[({"name": "llmd-auth-test"})],
19+
indirect=True,
20+
)
21+
class TestLLMISVCAuth:
22+
"""Authentication testing for LLMD."""
23+
24+
@pytest.fixture(scope="class", autouse=True)
25+
def setup_auth_resources(
26+
self,
27+
llmd_gateway,
28+
llmisvc_auth,
29+
llmisvc_auth_token,
30+
llmisvc_auth_view_role,
31+
llmisvc_auth_role_binding,
32+
):
33+
"""Set up gateway, LLMInferenceServices, and tokens once for all tests."""
34+
llmisvc_auth_prefix = "llmisvc-auth-user-"
35+
sa_prefix = "llmisvc-auth-sa-"
36+
37+
# Create LLMInferenceService instances using the factory fixture
38+
llmisvc_user_a, sa_user_a = llmisvc_auth(
39+
service_name=llmisvc_auth_prefix + "a",
40+
service_account_name=sa_prefix + "a",
41+
)
42+
llmisvc_user_b, sa_user_b = llmisvc_auth(
43+
service_name=llmisvc_auth_prefix + "b",
44+
service_account_name=sa_prefix + "b",
45+
)
46+
47+
# Create tokens with all RBAC resources
48+
token_user_a = llmisvc_auth_token(
49+
service_account=sa_user_a,
50+
llmisvc=llmisvc_user_a,
51+
view_role_factory=llmisvc_auth_view_role,
52+
role_binding_factory=llmisvc_auth_role_binding,
53+
)
54+
token_user_b = llmisvc_auth_token(
55+
service_account=sa_user_b,
56+
llmisvc=llmisvc_user_b,
57+
view_role_factory=llmisvc_auth_view_role,
58+
role_binding_factory=llmisvc_auth_role_binding,
59+
)
60+
61+
# Verify all resources are ready
62+
assert verify_gateway_status(llmd_gateway), "Gateway should be ready"
63+
assert verify_llm_service_status(llmisvc_user_a), "LLMInferenceService user A should be ready"
64+
assert verify_llm_service_status(llmisvc_user_b), "LLMInferenceService user B should be ready"
65+
66+
# Store resources as class attributes for use in tests
67+
TestLLMISVCAuth.llmisvc_user_a = llmisvc_user_a
68+
TestLLMISVCAuth.llmisvc_user_b = llmisvc_user_b
69+
TestLLMISVCAuth.token_user_a = token_user_a
70+
TestLLMISVCAuth.token_user_b = token_user_b
71+
72+
def test_llmisvc_authorized(self):
73+
"""Test that authorized users can access their own LLMInferenceServices."""
74+
# Verify inference for user A with user A's token (should succeed)
75+
verify_inference_response_llmd(
76+
llm_service=self.llmisvc_user_a,
77+
inference_config=TINYLLAMA_INFERENCE_CONFIG,
78+
inference_type="chat_completions",
79+
protocol=Protocols.HTTP,
80+
use_default_query=True,
81+
insecure=False,
82+
model_name=self.llmisvc_user_a.name,
83+
token=self.token_user_a,
84+
authorized_user=True,
85+
)
86+
87+
# Verify inference for user B with user B's token (should succeed)
88+
verify_inference_response_llmd(
89+
llm_service=self.llmisvc_user_b,
90+
inference_config=TINYLLAMA_INFERENCE_CONFIG,
91+
inference_type="chat_completions",
92+
protocol=Protocols.HTTP,
93+
use_default_query=True,
94+
insecure=False,
95+
model_name=self.llmisvc_user_b.name,
96+
token=self.token_user_b,
97+
authorized_user=True,
98+
)
99+
100+
def test_llmisvc_unauthorized(self):
101+
"""Test that unauthorized access to LLMInferenceServices is properly blocked."""
102+
# Verify that user B's token cannot access user A's service (should fail)
103+
verify_inference_response_llmd(
104+
llm_service=self.llmisvc_user_a,
105+
inference_config=TINYLLAMA_INFERENCE_CONFIG,
106+
inference_type="chat_completions",
107+
protocol=Protocols.HTTP,
108+
use_default_query=True,
109+
insecure=False,
110+
model_name=self.llmisvc_user_a.name,
111+
token=self.token_user_b,
112+
authorized_user=False,
113+
)
114+
115+
# Verify that accessing user A's service without a token fails
116+
verify_inference_response_llmd(
117+
llm_service=self.llmisvc_user_a,
118+
inference_config=TINYLLAMA_INFERENCE_CONFIG,
119+
inference_type="chat_completions",
120+
protocol=Protocols.HTTP,
121+
use_default_query=True,
122+
insecure=False,
123+
authorized_user=False,
124+
)

utilities/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ class S3:
296296

297297
class HuggingFace:
298298
TINYLLAMA: str = "hf://TinyLlama/TinyLlama-1.1B-Chat-v1.0"
299+
OPT125M: str = "hf://facebook/opt-125m"
299300

300301

301302
class OCIRegistry:

utilities/llmd_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class ModelStorage:
3535
TINYLLAMA_S3: str = SharedModelStorage.S3.TINYLLAMA
3636
S3_QWEN: str = SharedModelStorage.S3.QWEN_7B_INSTRUCT
3737
HF_TINYLLAMA: str = SharedModelStorage.HuggingFace.TINYLLAMA
38+
HF_OPT125M: str = SharedModelStorage.HuggingFace.OPT125M
3839

3940

4041
class ContainerImages:

utilities/llmd_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,9 @@ def create_llmisvc(
284284
template_config["imagePullSecrets"] = [{"name": secret} for secret in image_pull_secrets]
285285

286286
if enable_auth:
287-
annotations["serving.kserve.io/auth"] = "true"
287+
annotations["security.opendatahub.io/enable-auth"] = "true"
288+
else:
289+
annotations["security.opendatahub.io/enable-auth"] = "false"
288290

289291
LOGGER.info(f"Creating LLMInferenceService {name} in namespace {namespace}")
290292

0 commit comments

Comments
 (0)