Skip to content

Commit 0a6e588

Browse files
authored
feat: enable auth for LocalFS Files Provider (llamastack#2773)
# What does this PR do? Supports authentication for LocalFS Files provider. closes llamastack#2760 ## Test Plan CI. added tests.
1 parent dd30332 commit 0a6e588

File tree

4 files changed

+233
-11
lines changed

4 files changed

+233
-11
lines changed

llama_stack/providers/inline/files/localfs/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66

77
from typing import Any
88

9-
from llama_stack.distribution.datatypes import Api
9+
from llama_stack.distribution.datatypes import AccessRule, Api
1010

1111
from .config import LocalfsFilesImplConfig
1212
from .files import LocalfsFilesImpl
1313

1414
__all__ = ["LocalfsFilesImpl", "LocalfsFilesImplConfig"]
1515

1616

17-
async def get_provider_impl(config: LocalfsFilesImplConfig, deps: dict[Api, Any]):
18-
impl = LocalfsFilesImpl(config)
17+
async def get_provider_impl(config: LocalfsFilesImplConfig, deps: dict[Api, Any], policy: list[AccessRule]):
18+
impl = LocalfsFilesImpl(config, policy)
1919
await impl.initialize()
2020
return impl

llama_stack/providers/inline/files/localfs/files.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@
1919
OpenAIFileObject,
2020
OpenAIFilePurpose,
2121
)
22+
from llama_stack.distribution.datatypes import AccessRule
2223
from llama_stack.providers.utils.sqlstore.api import ColumnDefinition, ColumnType
23-
from llama_stack.providers.utils.sqlstore.sqlstore import SqlStore, sqlstore_impl
24+
from llama_stack.providers.utils.sqlstore.authorized_sqlstore import AuthorizedSqlStore
25+
from llama_stack.providers.utils.sqlstore.sqlstore import sqlstore_impl
2426

2527
from .config import LocalfsFilesImplConfig
2628

2729

2830
class LocalfsFilesImpl(Files):
29-
def __init__(self, config: LocalfsFilesImplConfig) -> None:
31+
def __init__(self, config: LocalfsFilesImplConfig, policy: list[AccessRule]) -> None:
3032
self.config = config
31-
self.sql_store: SqlStore | None = None
33+
self.policy = policy
34+
self.sql_store: AuthorizedSqlStore | None = None
3235

3336
async def initialize(self) -> None:
3437
"""Initialize the files provider by setting up storage directory and metadata database."""
@@ -37,7 +40,7 @@ async def initialize(self) -> None:
3740
storage_path.mkdir(parents=True, exist_ok=True)
3841

3942
# Initialize SQL store for metadata
40-
self.sql_store = sqlstore_impl(self.config.metadata_store)
43+
self.sql_store = AuthorizedSqlStore(sqlstore_impl(self.config.metadata_store))
4144
await self.sql_store.create_table(
4245
"openai_files",
4346
{
@@ -126,6 +129,7 @@ async def openai_list_files(
126129

127130
paginated_result = await self.sql_store.fetch_all(
128131
table="openai_files",
132+
policy=self.policy,
129133
where=where_conditions if where_conditions else None,
130134
order_by=[("created_at", order.value)],
131135
cursor=("id", after) if after else None,
@@ -156,7 +160,7 @@ async def openai_retrieve_file(self, file_id: str) -> OpenAIFileObject:
156160
if not self.sql_store:
157161
raise RuntimeError("Files provider not initialized")
158162

159-
row = await self.sql_store.fetch_one("openai_files", where={"id": file_id})
163+
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
160164
if not row:
161165
raise ValueError(f"File with id {file_id} not found")
162166

@@ -174,7 +178,7 @@ async def openai_delete_file(self, file_id: str) -> OpenAIFileDeleteResponse:
174178
if not self.sql_store:
175179
raise RuntimeError("Files provider not initialized")
176180

177-
row = await self.sql_store.fetch_one("openai_files", where={"id": file_id})
181+
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
178182
if not row:
179183
raise ValueError(f"File with id {file_id} not found")
180184

@@ -197,7 +201,7 @@ async def openai_retrieve_file_content(self, file_id: str) -> Response:
197201
raise RuntimeError("Files provider not initialized")
198202

199203
# Get file metadata
200-
row = await self.sql_store.fetch_one("openai_files", where={"id": file_id})
204+
row = await self.sql_store.fetch_one("openai_files", policy=self.policy, where={"id": file_id})
201205
if not row:
202206
raise ValueError(f"File with id {file_id} not found")
203207

tests/integration/files/test_files.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
# the root directory of this source tree.
66

77
from io import BytesIO
8+
from unittest.mock import patch
89

910
import pytest
1011
from openai import OpenAI
1112

13+
from llama_stack.distribution.datatypes import User
1214
from llama_stack.distribution.library_client import LlamaStackAsLibraryClient
1315

1416

@@ -61,3 +63,218 @@ def test_openai_client_basic_operations(compat_client, client_with_models):
6163
except Exception:
6264
pass
6365
raise e
66+
67+
68+
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
69+
def test_files_authentication_isolation(mock_get_authenticated_user, compat_client, client_with_models):
70+
"""Test that users can only access their own files."""
71+
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
72+
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
73+
if not isinstance(client_with_models, LlamaStackAsLibraryClient):
74+
pytest.skip("Authentication tests require LlamaStackAsLibraryClient (library mode)")
75+
76+
client = compat_client
77+
78+
# Create two test users
79+
user1 = User("user1", {"roles": ["user"], "teams": ["team-a"]})
80+
user2 = User("user2", {"roles": ["user"], "teams": ["team-b"]})
81+
82+
# User 1 uploads a file
83+
mock_get_authenticated_user.return_value = user1
84+
test_content_1 = b"User 1's private file content"
85+
86+
with BytesIO(test_content_1) as file_buffer:
87+
file_buffer.name = "user1_file.txt"
88+
user1_file = client.files.create(file=file_buffer, purpose="assistants")
89+
90+
# User 2 uploads a file
91+
mock_get_authenticated_user.return_value = user2
92+
test_content_2 = b"User 2's private file content"
93+
94+
with BytesIO(test_content_2) as file_buffer:
95+
file_buffer.name = "user2_file.txt"
96+
user2_file = client.files.create(file=file_buffer, purpose="assistants")
97+
98+
try:
99+
# User 1 can see their own file
100+
mock_get_authenticated_user.return_value = user1
101+
user1_files = client.files.list()
102+
user1_file_ids = [f.id for f in user1_files.data]
103+
assert user1_file.id in user1_file_ids
104+
assert user2_file.id not in user1_file_ids # Cannot see user2's file
105+
106+
# User 2 can see their own file
107+
mock_get_authenticated_user.return_value = user2
108+
user2_files = client.files.list()
109+
user2_file_ids = [f.id for f in user2_files.data]
110+
assert user2_file.id in user2_file_ids
111+
assert user1_file.id not in user2_file_ids # Cannot see user1's file
112+
113+
# User 1 can retrieve their own file
114+
mock_get_authenticated_user.return_value = user1
115+
retrieved_file = client.files.retrieve(user1_file.id)
116+
assert retrieved_file.id == user1_file.id
117+
118+
# User 1 cannot retrieve user2's file
119+
mock_get_authenticated_user.return_value = user1
120+
with pytest.raises(ValueError, match="not found"):
121+
client.files.retrieve(user2_file.id)
122+
123+
# User 1 can access their file content
124+
mock_get_authenticated_user.return_value = user1
125+
content_response = client.files.content(user1_file.id)
126+
if isinstance(content_response, str):
127+
content = bytes(content_response, "utf-8")
128+
else:
129+
content = content_response.content
130+
assert content == test_content_1
131+
132+
# User 1 cannot access user2's file content
133+
mock_get_authenticated_user.return_value = user1
134+
with pytest.raises(ValueError, match="not found"):
135+
client.files.content(user2_file.id)
136+
137+
# User 1 can delete their own file
138+
mock_get_authenticated_user.return_value = user1
139+
delete_response = client.files.delete(user1_file.id)
140+
assert delete_response.deleted is True
141+
142+
# User 1 cannot delete user2's file
143+
mock_get_authenticated_user.return_value = user1
144+
with pytest.raises(ValueError, match="not found"):
145+
client.files.delete(user2_file.id)
146+
147+
# User 2 can still access their file after user1's file is deleted
148+
mock_get_authenticated_user.return_value = user2
149+
retrieved_file = client.files.retrieve(user2_file.id)
150+
assert retrieved_file.id == user2_file.id
151+
152+
# Cleanup user2's file
153+
mock_get_authenticated_user.return_value = user2
154+
client.files.delete(user2_file.id)
155+
156+
except Exception as e:
157+
# Cleanup in case of failure
158+
try:
159+
mock_get_authenticated_user.return_value = user1
160+
client.files.delete(user1_file.id)
161+
except Exception:
162+
pass
163+
try:
164+
mock_get_authenticated_user.return_value = user2
165+
client.files.delete(user2_file.id)
166+
except Exception:
167+
pass
168+
raise e
169+
170+
171+
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
172+
def test_files_authentication_shared_attributes(mock_get_authenticated_user, compat_client, client_with_models):
173+
"""Test access control with users having identical attributes."""
174+
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
175+
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
176+
if not isinstance(client_with_models, LlamaStackAsLibraryClient):
177+
pytest.skip("Authentication tests require LlamaStackAsLibraryClient (library mode)")
178+
179+
client = compat_client
180+
181+
# Create users with identical attributes (required for default policy)
182+
user_a = User("user-a", {"roles": ["user"], "teams": ["shared-team"]})
183+
user_b = User("user-b", {"roles": ["user"], "teams": ["shared-team"]})
184+
185+
# User A uploads a file
186+
mock_get_authenticated_user.return_value = user_a
187+
test_content = b"Shared attributes file content"
188+
189+
with BytesIO(test_content) as file_buffer:
190+
file_buffer.name = "shared_attributes_file.txt"
191+
shared_file = client.files.create(file=file_buffer, purpose="assistants")
192+
193+
try:
194+
# User B with identical attributes can access the file
195+
mock_get_authenticated_user.return_value = user_b
196+
files_list = client.files.list()
197+
file_ids = [f.id for f in files_list.data]
198+
199+
# User B should be able to see the file due to identical attributes
200+
assert shared_file.id in file_ids
201+
202+
# User B can retrieve file info
203+
retrieved_file = client.files.retrieve(shared_file.id)
204+
assert retrieved_file.id == shared_file.id
205+
206+
# User B can access file content
207+
content_response = client.files.content(shared_file.id)
208+
if isinstance(content_response, str):
209+
content = bytes(content_response, "utf-8")
210+
else:
211+
content = content_response.content
212+
assert content == test_content
213+
214+
# Cleanup
215+
mock_get_authenticated_user.return_value = user_a
216+
client.files.delete(shared_file.id)
217+
218+
except Exception as e:
219+
# Cleanup in case of failure
220+
try:
221+
mock_get_authenticated_user.return_value = user_a
222+
client.files.delete(shared_file.id)
223+
except Exception:
224+
pass
225+
try:
226+
mock_get_authenticated_user.return_value = user_b
227+
client.files.delete(shared_file.id)
228+
except Exception:
229+
pass
230+
raise e
231+
232+
233+
@patch("llama_stack.providers.utils.sqlstore.authorized_sqlstore.get_authenticated_user")
234+
def test_files_authentication_anonymous_access(mock_get_authenticated_user, compat_client, client_with_models):
235+
"""Test anonymous user behavior when no authentication is present."""
236+
if isinstance(client_with_models, LlamaStackAsLibraryClient) and isinstance(compat_client, OpenAI):
237+
pytest.skip("OpenAI files are not supported when testing with LlamaStackAsLibraryClient")
238+
if not isinstance(client_with_models, LlamaStackAsLibraryClient):
239+
pytest.skip("Authentication tests require LlamaStackAsLibraryClient (library mode)")
240+
241+
client = compat_client
242+
243+
# Simulate anonymous user (no authentication)
244+
mock_get_authenticated_user.return_value = None
245+
246+
test_content = b"Anonymous file content"
247+
248+
with BytesIO(test_content) as file_buffer:
249+
file_buffer.name = "anonymous_file.txt"
250+
anonymous_file = client.files.create(file=file_buffer, purpose="assistants")
251+
252+
try:
253+
# Anonymous user should be able to access their own uploaded file
254+
files_list = client.files.list()
255+
file_ids = [f.id for f in files_list.data]
256+
assert anonymous_file.id in file_ids
257+
258+
# Can retrieve file info
259+
retrieved_file = client.files.retrieve(anonymous_file.id)
260+
assert retrieved_file.id == anonymous_file.id
261+
262+
# Can access file content
263+
content_response = client.files.content(anonymous_file.id)
264+
if isinstance(content_response, str):
265+
content = bytes(content_response, "utf-8")
266+
else:
267+
content = content_response.content
268+
assert content == test_content
269+
270+
# Can delete the file
271+
delete_response = client.files.delete(anonymous_file.id)
272+
assert delete_response.deleted is True
273+
274+
except Exception as e:
275+
# Cleanup in case of failure
276+
try:
277+
client.files.delete(anonymous_file.id)
278+
except Exception:
279+
pass
280+
raise e

tests/unit/files/test_files.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from llama_stack.apis.common.responses import Order
1111
from llama_stack.apis.files import OpenAIFilePurpose
12+
from llama_stack.distribution.access_control.access_control import default_policy
1213
from llama_stack.providers.inline.files.localfs import (
1314
LocalfsFilesImpl,
1415
LocalfsFilesImplConfig,
@@ -38,7 +39,7 @@ async def files_provider(tmp_path):
3839
storage_dir=storage_dir.as_posix(), metadata_store=SqliteSqlStoreConfig(db_path=db_path.as_posix())
3940
)
4041

41-
provider = LocalfsFilesImpl(config)
42+
provider = LocalfsFilesImpl(config, default_policy())
4243
await provider.initialize()
4344
yield provider
4445

0 commit comments

Comments
 (0)