Skip to content

Commit bd14fa5

Browse files
committed
fix: access control to fail-closed when owner attributes are missing
Changed UserInOwnersList.matches() to return False instead of True when a resource's owner attributes are None. This prevents unintended access when resource when owner attributes aren't present. For example, checking "user in owners teams" now returns False if the resource has no teams attribute, rather than defaulting to True. Added "ResourceIsUnowned" to allow access to unowned resources. Updated default_policy to use multiple separate "user in owners" AccessRules instead of a single rule with multiple when clauses. With the new fail-closed behavior, only one rule needs to match. Added a "user is owner" rule to handle resources. Also added ResourceIsUnowned to allow access to unowned resources. Updated the SQL generated by _build_default_policy_where_clause to reflect the above changes. Closes: #4272 Signed-off-by: Derek Higgins <[email protected]>
1 parent c4c6d39 commit bd14fa5

File tree

5 files changed

+79
-52
lines changed

5 files changed

+79
-52
lines changed

src/llama_stack/core/access_control/access_control.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,17 @@ def default_policy() -> list[AccessRule]:
6666
return [
6767
AccessRule(
6868
permit=Scope(actions=list(Action)),
69-
when=["user in owners " + name for name in ["roles", "teams", "projects", "namespaces"]],
69+
when=["user in owners " + name],
70+
)
71+
for name in ["roles", "teams", "projects", "namespaces"]
72+
] + [
73+
AccessRule(
74+
permit=Scope(actions=list(Action)),
75+
when=["user is owner"],
76+
),
77+
AccessRule(
78+
permit=Scope(actions=list(Action)),
79+
when=["resource is unowned"],
7080
),
7181
]
7282

src/llama_stack/core/access_control/conditions.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def owners_values(self, resource: ProtectedResource) -> list[str] | None:
4040
def matches(self, resource: ProtectedResource, user: User) -> bool:
4141
required = self.owners_values(resource)
4242
if not required:
43-
return True
43+
return False
4444
if not user.attributes or self.name not in user.attributes or not user.attributes[self.name]:
4545
return False
4646
user_values = user.attributes[self.name]
@@ -106,6 +106,14 @@ def __repr__(self):
106106
return "user is not owner"
107107

108108

109+
class ResourceIsUnowned:
110+
def matches(self, resource: ProtectedResource, user: User) -> bool:
111+
return not resource.owner
112+
113+
def __repr__(self):
114+
return "resource is unowned"
115+
116+
109117
def parse_condition(condition: str) -> Condition:
110118
words = condition.split()
111119
match words:
@@ -121,6 +129,8 @@ def parse_condition(condition: str) -> Condition:
121129
return UserInOwnersList(name)
122130
case ["user", "not", "in", "owners", name]:
123131
return UserNotInOwnersList(name)
132+
case ["resource", "is", "unowned"]:
133+
return ResourceIsUnowned()
124134
case _:
125135
raise ValueError(f"Invalid condition: {condition}")
126136

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

Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,26 @@
2323
# WARNING: If default_policy() changes, this constant must be updated accordingly
2424
# or SQL filtering will fall back to conservative mode (safe but less performant)
2525
#
26-
# This policy represents: "Permit all actions when user is in owners list for ALL attribute categories"
26+
# This policy represents: "Permit all actions when user is in owners list for ANY attribute category"
2727
# The corresponding SQL logic is implemented in _build_default_policy_where_clause():
2828
# - Public records (no access_attributes) are always accessible
29-
# - Records with access_attributes require user to match ALL categories that exist in the resource
30-
# - Missing categories in the resource are treated as "no restriction" (allow)
29+
# - Records with access_attributes require user to match ANY category that exists in the resource
3130
# - Within each category, user needs ANY matching value (OR logic)
32-
# - Between categories, user needs ALL categories to match (AND logic)
31+
# - Between categories, user needs ANY category to match (OR logic)
3332
SQL_OPTIMIZED_POLICY = [
3433
AccessRule(
3534
permit=Scope(actions=list(Action)),
36-
when=["user in owners roles", "user in owners teams", "user in owners projects", "user in owners namespaces"],
35+
when=["user in owners " + name],
36+
)
37+
for name in ["roles", "teams", "projects", "namespaces"]
38+
] + [
39+
AccessRule(
40+
permit=Scope(actions=list(Action)),
41+
when=["user is owner"],
42+
),
43+
AccessRule(
44+
permit=Scope(actions=list(Action)),
45+
when=["resource is unowned"],
3746
),
3847
]
3948

@@ -279,53 +288,40 @@ def _get_public_access_conditions(self) -> list[str]:
279288
280289
Public records are those with:
281290
- owner_principal = '' (empty string)
282-
- access_attributes is either SQL NULL or JSON null
283291
284-
Note: Different databases serialize None differently:
285-
- SQLite: None → JSON null (text = 'null')
286-
- Postgres: None → SQL NULL (IS NULL)
292+
The policy "resource is unowned" only checks if owner_principal is empty,
293+
regardless of access_attributes.
287294
"""
288-
conditions = ["owner_principal = ''"]
289-
if self.database_type == StorageBackendType.SQL_POSTGRES.value:
290-
# Accept both SQL NULL and JSON null for Postgres compatibility
291-
# This handles both old rows (SQL NULL) and new rows (JSON null)
292-
conditions.append("(access_attributes IS NULL OR access_attributes::text = 'null')")
293-
elif self.database_type == StorageBackendType.SQL_SQLITE.value:
294-
# SQLite serializes None as JSON null
295-
conditions.append("(access_attributes IS NULL OR access_attributes = 'null')")
296-
else:
297-
raise ValueError(f"Unsupported database type: {self.database_type}")
298-
return conditions
295+
return ["owner_principal = ''"]
299296

300297
def _build_default_policy_where_clause(self, current_user: User | None) -> str:
301298
"""Build SQL WHERE clause for the default policy.
302299
303300
Default policy: permit all actions when user in owners [roles, teams, projects, namespaces]
304-
This means user must match ALL attribute categories that exist in the resource.
301+
This means user must match ANY attribute category that exists in the resource (OR logic).
305302
"""
306303
base_conditions = self._get_public_access_conditions()
307-
user_attr_conditions = []
308-
309-
if current_user and current_user.attributes:
310-
for attr_key, user_values in current_user.attributes.items():
311-
if user_values:
312-
value_conditions = []
313-
for value in user_values:
314-
# Check if JSON array contains the value
315-
escaped_value = value.replace("'", "''")
316-
json_text = self._json_extract_text("access_attributes", attr_key)
317-
value_conditions.append(f"({json_text} LIKE '%\"{escaped_value}\"%')")
318-
319-
if value_conditions:
320-
# Check if the category is missing (NULL)
321-
category_missing = f"{self._json_extract('access_attributes', attr_key)} IS NULL"
322-
user_matches_category = f"({' OR '.join(value_conditions)})"
323-
user_attr_conditions.append(f"({category_missing} OR {user_matches_category})")
324-
325-
if user_attr_conditions:
326-
all_requirements_met = f"({' AND '.join(user_attr_conditions)})"
327-
base_conditions.append(all_requirements_met)
328304

305+
if current_user:
306+
# Add "user is owner" condition - user's principal matches owner_principal
307+
escaped_principal = current_user.principal.replace("'", "''")
308+
base_conditions.append(f"owner_principal = '{escaped_principal}'")
309+
310+
# Add "user in owners" conditions for attribute matching
311+
if current_user.attributes:
312+
for attr_key, user_values in current_user.attributes.items():
313+
if user_values:
314+
value_conditions = []
315+
for value in user_values:
316+
# Check if JSON array contains the value
317+
escaped_value = value.replace("'", "''")
318+
json_text = self._json_extract_text("access_attributes", attr_key)
319+
value_conditions.append(f"({json_text} LIKE '%\"{escaped_value}\"%')")
320+
321+
if value_conditions:
322+
# User matches this category if any of their values match
323+
user_matches_category = f"({' OR '.join(value_conditions)})"
324+
base_conditions.append(user_matches_category)
329325
return f"({' OR '.join(base_conditions)})"
330326

331327
def _build_conservative_where_clause(self) -> str:

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ async def test_authorized_store_attributes(mock_get_authenticated_user, authoriz
184184
f"Category missing logic failed: expected 4,5 but got {category_test_ids}"
185185
)
186186

187+
# Test a user that has all roles and teams (should generate SQL)
188+
# owner_principal = ''
189+
# owner_principal = 'super-user'
190+
# ((JSON_EXTRACT(access_attributes, '$.roles') LIKE '%"admin"%') OR (JSON_EXTRACT(access_attributes, '$.roles') LIKE '%"user"%'))
191+
# ((JSON_EXTRACT(access_attributes, '$.teams') LIKE '%"dev"%') OR (JSON_EXTRACT(access_attributes, '$.teams') LIKE '%"qa"%'))
192+
super_user = User("super-user", {"roles": ["admin", "user"], "teams": ["dev", "qa"]})
193+
mock_get_authenticated_user.return_value = super_user
194+
result = await authorized_store.fetch_all(table_name)
195+
assert len(result.data) == 6
196+
187197
finally:
188198
# Clean up records
189199
await cleanup_records(authorized_store.sql_store, table_name, ["1", "2", "3", "4", "5", "6"])

tests/unit/server/test_access_control.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async def test_access_control_with_cache(mock_get_authenticated_user, test_setup
7878
with pytest.raises(ValueError):
7979
await routing_table.get_model("model-data-scientist")
8080

81-
mock_get_authenticated_user.return_value = User("test-user", {"roles": ["data-scientist"], "teams": ["other-team"]})
81+
mock_get_authenticated_user.return_value = User("test-user", {"roles": ["user"], "teams": ["other-team"]})
8282
all_models = await routing_table.list_models()
8383
assert len(all_models.data) == 1
8484
assert all_models.data[0].identifier == "model-public"
@@ -154,16 +154,16 @@ async def test_access_control_empty_attributes(mock_get_authenticated_user, test
154154
)
155155
await registry.register(model)
156156
mock_get_authenticated_user.return_value = User(
157-
"test-user",
157+
"differentuser",
158158
{
159159
"roles": [],
160160
},
161161
)
162-
result = await routing_table.get_model("model-empty-attrs")
163-
assert result.identifier == "model-empty-attrs"
162+
with pytest.raises(ValueError):
163+
await routing_table.get_model("model-empty-attrs")
164164
all_models = await routing_table.list_models()
165165
model_ids = [m.identifier for m in all_models.data]
166-
assert "model-empty-attrs" in model_ids
166+
assert "model-empty-attrs" not in model_ids
167167

168168

169169
@patch("llama_stack.core.routing_tables.common.get_authenticated_user")
@@ -223,7 +223,7 @@ async def test_automatic_access_attributes(mock_get_authenticated_user, test_set
223223
assert registered_model.owner.attributes["projects"] == ["llama-3"]
224224

225225
# Verify another user without matching attributes can't access it
226-
mock_get_authenticated_user.return_value = User("test-user", {"roles": ["engineer"], "teams": ["infra-team"]})
226+
mock_get_authenticated_user.return_value = User("test-user2", {"roles": ["engineer"], "teams": ["infra-team"]})
227227
with pytest.raises(ValueError):
228228
await routing_table.get_model("auto-access-model")
229229

@@ -363,6 +363,7 @@ def test_permit_when():
363363

364364

365365
def test_permit_unless():
366+
# permit unless both conditions are met
366367
config = """
367368
- permit:
368369
principal: user-1
@@ -377,10 +378,10 @@ def test_permit_unless():
377378
identifier="mymodel",
378379
provider_id="myprovider",
379380
model_type=ModelType.llm,
380-
owner=User("testuser", {"namespaces": ["foo"]}),
381+
owner=User("testuser", {"namespaces": ["foo"], "teams": ["ml-team"]}),
381382
)
382383
assert is_action_allowed(policy, "read", model, User("user-1", {"namespaces": ["foo"]}))
383-
assert not is_action_allowed(policy, "read", model, User("user-1", {"namespaces": ["bar"]}))
384+
assert not is_action_allowed(policy, "read", model, User("user-1", {"namespaces": ["bar"], "teams": ["ml-team"]}))
384385
assert not is_action_allowed(policy, "read", model, User("user-2", {"namespaces": ["foo"]}))
385386

386387

0 commit comments

Comments
 (0)