Skip to content

Commit fcd6370

Browse files
authored
fix: set SqlRecord owner to None when owner_principal is empty (#4284)
Changes SqlRecord creation in AuthorizedSqlStore.fetch_all to use owner=None when owner_principal is empty/missing, matching the ResourceWithOwner pattern used in routing tables. This fixes an inconsistency where SQL store was creating User(principal="") while routing tables use owner=None for public resources. Changes: o Update ProtectedResource Protocol to allow owner: User | None o Update SqlRecord.__init__ to accept owner: User | None o Update fetch_all to create owner=None for records without owner_principal Signed-off-by: Derek Higgins <derekh@redhat.com>
1 parent aa3898f commit fcd6370

4 files changed

Lines changed: 64 additions & 11 deletions

File tree

src/llama_stack/core/access_control/conditions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class User(Protocol):
1515
class ProtectedResource(Protocol):
1616
type: str
1717
identifier: str
18-
owner: User
18+
owner: User | None
1919

2020

2121
class Condition(Protocol):

src/llama_stack/core/storage/sqlstore/authorized_sqlstore.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def _enhance_item_with_access_control(item: Mapping[str, Any], current_user: Use
5656

5757

5858
class SqlRecord(ProtectedResource):
59-
def __init__(self, record_id: str, table_name: str, owner: User):
59+
def __init__(self, record_id: str, table_name: str, owner: User | None):
6060
self.type = f"sql_record::{table_name}"
6161
self.identifier = record_id
6262
self.owner = owner
@@ -171,12 +171,16 @@ async def fetch_all(
171171

172172
for row in rows.data:
173173
stored_access_attrs = row.get("access_attributes")
174-
stored_owner_principal = row.get("owner_principal") or ""
174+
stored_owner_principal = row.get("owner_principal")
175175

176176
record_id = row.get("id", "unknown")
177-
sql_record = SqlRecord(
178-
str(record_id), table, User(principal=stored_owner_principal, attributes=stored_access_attrs)
177+
# Create owner as None if owner_principal is empty/missing, matching ResourceWithOwner behavior
178+
owner = (
179+
User(principal=stored_owner_principal, attributes=stored_access_attrs)
180+
if stored_owner_principal
181+
else None
179182
)
183+
sql_record = SqlRecord(str(record_id), table, owner)
180184

181185
if is_action_allowed(self.policy, action, sql_record, current_user):
182186
filtered_rows.append(row)

tests/integration/providers/utils/sqlstore/test_authorized_sqlstore.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,36 @@ async def test_user_ownership_policy(mock_get_authenticated_user, authorized_sto
247247
finally:
248248
# Clean up records
249249
await cleanup_records(authorized_store.sql_store, table_name, ["1", "2"])
250+
251+
252+
@pytest.mark.parametrize("backend_config", BACKEND_CONFIGS)
253+
@patch("llama_stack.core.storage.sqlstore.authorized_sqlstore.get_authenticated_user")
254+
async def test_sqlrecord_created_with_no_owner(mock_get_authenticated_user, authorized_store, request):
255+
"""Test that SqlRecord is created with no owner == None when owner_principal is empty/missing"""
256+
backend_name = request.node.callspec.id
257+
258+
# Create test table
259+
table_name = f"test_sqlrecord_created_with_no_owner_{backend_name}"
260+
await create_test_table(authorized_store, table_name)
261+
262+
try:
263+
# Test with no authenticated user (should handle JSON null comparison)
264+
mock_get_authenticated_user.return_value = None
265+
266+
# Insert some test data
267+
await authorized_store.insert(table_name, {"id": "1", "data": "public_data"})
268+
269+
# Test fetching with no user - should create SqlRecord with no owner
270+
with patch(
271+
"llama_stack.core.storage.sqlstore.authorized_sqlstore.is_action_allowed", return_value=True
272+
) as mock_is_action_allowed:
273+
result = await authorized_store.fetch_all(table_name)
274+
mock_is_action_allowed.assert_called_once()
275+
args = mock_is_action_allowed.call_args
276+
assert args[0][2].type == f"sql_record::{table_name}"
277+
assert args[0][2].owner is None
278+
assert len(result.data) == 1
279+
280+
finally:
281+
# Clean up records
282+
await cleanup_records(authorized_store.sql_store, table_name, ["1"])

tests/unit/utils/test_authorized_sqlstore.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,22 +101,32 @@ async def test_sql_policy_consistency(mock_get_authenticated_user):
101101
# Test scenarios with different access control patterns
102102
test_scenarios = [
103103
# Scenario 1: Public record (no access control - represents None user insert)
104-
{"id": "1", "name": "public", "access_attributes": None},
104+
{"id": "1", "name": "public", "owner_principal": "", "access_attributes": None},
105105
# Scenario 2: Record with roles requirement
106-
{"id": "2", "name": "admin-only", "access_attributes": {"roles": ["admin"]}},
106+
{"id": "2", "name": "admin-only", "owner_principal": "owner1", "access_attributes": {"roles": ["admin"]}},
107107
# Scenario 3: Record with multiple attribute categories
108-
{"id": "3", "name": "admin-ml-team", "access_attributes": {"roles": ["admin"], "teams": ["ml-team"]}},
108+
{
109+
"id": "3",
110+
"name": "admin-ml-team",
111+
"owner_principal": "owner2",
112+
"access_attributes": {"roles": ["admin"], "teams": ["ml-team"]},
113+
},
109114
# Scenario 4: Record with teams only (missing roles category)
110-
{"id": "4", "name": "ml-team-only", "access_attributes": {"teams": ["ml-team"]}},
115+
{
116+
"id": "4",
117+
"name": "ml-team-only",
118+
"owner_principal": "owner3",
119+
"access_attributes": {"teams": ["ml-team"]},
120+
},
111121
# Scenario 5: Record with roles and projects
112122
{
113123
"id": "5",
114124
"name": "admin-project-x",
125+
"owner_principal": "owner4",
115126
"access_attributes": {"roles": ["admin"], "projects": ["project-x"]},
116127
},
117128
]
118129

119-
mock_get_authenticated_user.return_value = User("test-user", {"roles": ["admin"]})
120130
for scenario in test_scenarios:
121131
await base_sqlstore.insert("resources", scenario)
122132

@@ -148,10 +158,16 @@ async def test_sql_policy_consistency(mock_get_authenticated_user):
148158
sql_ids = {row["id"] for row in sql_results.data}
149159
policy_ids = set()
150160
for scenario in test_scenarios:
161+
# Create owner matching what was stored (None for public records)
162+
owner = (
163+
User(principal=scenario["owner_principal"], attributes=scenario["access_attributes"])
164+
if scenario["owner_principal"]
165+
else None
166+
)
151167
sql_record = SqlRecord(
152168
record_id=scenario["id"],
153169
table_name="resources",
154-
owner=User(principal="test-user", attributes=scenario["access_attributes"]),
170+
owner=owner,
155171
)
156172

157173
if is_action_allowed(policy, Action.READ, sql_record, user):

0 commit comments

Comments
 (0)