Skip to content

Commit e495ee5

Browse files
security(studio): allowlist Studio variable-values endpoint (#259)
* security(studio): allowlist Studio variable-values endpoint GET /studio/variables/{resource_type}/{identifier} (and the underlying VariableValueService.get_values_for_subject[s]) read spp.data.value with sudo, no allowlist, no company filter, and with the default variables="*" returned EVERY cached value for a subject -- including DCI/CRVS (inter-registry health/vital) and scoring/PMT values -- to any client holding the generic studio:read scope. Same exposure class as the Data API /Data/pull leak fixed in #257, on a sibling endpoint/scope (found by #257's post-implementation review). Reuse the #257 allowlist: restrict both service methods to API-pullable variables (spp.cel.variable._get_data_api_pullable_domain -> ordinary external-provider only; DCI-backed/computed/scoring excluded) by intersecting on cel_accessor, and add the missing company_id filter. The pullable-variable lookup is sudo (system config; API authz is the scope check). Tests: service-level (excludes non-pullable + scoring, company isolation) and the existing endpoint/value tests updated to use external-provider fixtures (the only pullable kind under the new policy). spp_studio_api_v2 162/162. Stacked on #257 (security-dci-crvs-cache) for the shared allowlist primitive; retarget PR base to 19.0 after #257 merges. * style: ruff-format empty-allowlist test call
1 parent 9cc5bc6 commit e495ee5

3 files changed

Lines changed: 176 additions & 15 deletions

File tree

spp_studio_api_v2/services/variable_value_service.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ class VariableValueService:
3434
def __init__(self, env: Environment):
3535
self.env = env
3636

37+
def _data_api_pullable_accessors(self):
38+
"""cel_accessors whose cached values may be exposed through the API.
39+
40+
Mirrors the Data API allowlist (`spp.cel.variable._get_data_api_pullable_domain`):
41+
only ordinary external-provider variables are returnable; DCI-backed
42+
(inter-registry health/vital) and computed/scoring/aggregate values are
43+
excluded so this endpoint cannot become an oracle for sensitive cached
44+
data. Cache rows are keyed by the variable's cel_accessor.
45+
"""
46+
if "spp.cel.variable" not in self.env:
47+
return []
48+
# sudo: the allowlist is system config; API authorization is the scope
49+
# check at the router. The API client user has no spp.cel.variable ACL.
50+
Variable = self.env["spp.cel.variable"].sudo() # nosemgrep: odoo-sudo-without-context
51+
return Variable.search(Variable._get_data_api_pullable_domain()).mapped("cel_accessor")
52+
3753
def get_values_for_subject(
3854
self,
3955
partner_id: int,
@@ -66,11 +82,15 @@ def get_values_for_subject(
6682
return {}
6783
DataValue = self.env["spp.data.value"]
6884

69-
# Build domain
85+
# Build domain. Restrict to API-pullable variables and the current
86+
# company so this endpoint cannot disclose sensitive cached values
87+
# (DCI/CRVS, scoring) or cross-company data.
7088
domain = [
7189
("subject_model", "=", "res.partner"),
7290
("subject_id", "=", partner_id),
7391
("period_key", "=", period_key),
92+
("company_id", "=", self.env.company.id),
93+
("variable_name", "in", self._data_api_pullable_accessors()),
7494
]
7595

7696
# Filter by variable names if specified
@@ -136,11 +156,15 @@ def get_values_for_subjects(
136156
return {}
137157
DataValue = self.env["spp.data.value"]
138158

139-
# Build domain
159+
# Build domain. Restrict to API-pullable variables and the current
160+
# company (see get_values_for_subject) so sensitive cached values are
161+
# not disclosed in bulk.
140162
domain = [
141163
("subject_model", "=", "res.partner"),
142164
("subject_id", "in", partner_ids),
143165
("period_key", "=", period_key),
166+
("company_id", "=", self.env.company.id),
167+
("variable_name", "in", self._data_api_pullable_accessors()),
144168
]
145169

146170
if variable_names and "*" not in variable_names:

spp_studio_api_v2/tests/test_studio_router.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,32 +62,60 @@ def setUpClass(cls):
6262
}
6363
)
6464

65+
# The Studio variable-values endpoint only exposes API-pullable
66+
# (ordinary external-provider) variables, so the fixture variables are
67+
# external-provider; otherwise their cached values are filtered out.
68+
cls.value_provider = cls.env["spp.data.provider"].search([("code", "=", "studio_test_provider")], limit=1)
69+
if not cls.value_provider:
70+
cls.value_provider = cls.env["spp.data.provider"].create(
71+
{"name": "Studio Test Provider", "code": "studio_test_provider"}
72+
)
73+
6574
# Get or create CEL variable (avoid unique constraint on cel_accessor)
6675
cls.cel_variable = cls.env["spp.cel.variable"].search(
6776
[("cel_accessor", "=", "age"), ("applies_to", "=", "individual")], limit=1
6877
)
78+
_age_vals = {
79+
"source_type": "external",
80+
"external_provider_id": cls.value_provider.id,
81+
"state": "active",
82+
"category_id": cls.variable_category.id,
83+
}
6984
if not cls.cel_variable:
7085
cls.cel_variable = cls.env["spp.cel.variable"].create(
7186
{
7287
"name": "Age",
7388
"cel_accessor": "age",
74-
"source_type": "field",
7589
"value_type": "number",
76-
"state": "active",
7790
"applies_to": "individual",
78-
"source_model": "res.partner",
79-
"source_field": "age",
8091
"period_granularity": "current",
8192
"supports_historical": False,
82-
"category_id": cls.variable_category.id,
93+
**_age_vals,
8394
}
8495
)
8596
else:
86-
# Update existing variable to use the test category
87-
cls.cel_variable.write(
97+
# Update existing variable to the pullable (external-provider) shape.
98+
cls.cel_variable.write(_age_vals)
99+
100+
# 'income' fixture used by the variable-filter tests (also pullable).
101+
# spp_studio seeds non-external 'income' variables (standard_variables.xml),
102+
# so make the existing ones pullable rather than skipping when present.
103+
_income_vals = {
104+
"source_type": "external",
105+
"external_provider_id": cls.value_provider.id,
106+
"state": "active",
107+
}
108+
income_vars = cls.env["spp.cel.variable"].search([("cel_accessor", "=", "income")])
109+
if income_vars:
110+
income_vars.write(_income_vals)
111+
else:
112+
cls.env["spp.cel.variable"].create(
88113
{
89-
"category_id": cls.variable_category.id,
90-
"state": "active",
114+
"name": "Income",
115+
"cel_accessor": "income",
116+
"value_type": "number",
117+
"applies_to": "both",
118+
**_income_vals,
91119
}
92120
)
93121

@@ -845,8 +873,9 @@ def test_list_variables_returns_active_variables(self):
845873
self.assertIsNotNone(var_item)
846874
self.assertIn("Age", var_item["label"]) # May be "Age" or "Age (Years)"
847875
self.assertEqual(var_item["valueType"], "number")
848-
# sourceType may be "field" or "computed" depending on demo data
849-
self.assertIn(var_item["sourceType"], ["field", "computed"])
876+
# sourceType may be "field"/"computed" (demo data) or "external" (the
877+
# pullable fixture this suite uses for the subject-values endpoint).
878+
self.assertIn(var_item["sourceType"], ["field", "computed", "external"])
850879
self.assertIn(var_item["appliesTo"], ["individual", "both"])
851880
self.assertIn(
852881
var_item["periodGranularity"],

spp_studio_api_v2/tests/test_variable_value_service.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ def setUp(self):
2525
}
2626
)
2727

28-
# Create test variable with unique name
28+
# The Studio variable-values endpoint only exposes API-pullable
29+
# (ordinary external-provider) variables, so the fixture variable is
30+
# external-provider to exercise the "value is returned" path.
31+
self.provider = self.env["spp.data.provider"].create(
32+
{"name": f"Edu {self._test_id}", "code": f"edu_{self._test_id}"}
33+
)
2934
self.test_variable = self.env["spp.cel.variable"].create(
3035
{
3136
"name": self._var_name,
3237
"cel_accessor": self._var_name,
33-
"source_type": "field",
38+
"source_type": "external",
39+
"external_provider_id": self.provider.id,
3440
"value_type": "number",
3541
"state": "active",
3642
"applies_to": "both",
@@ -134,6 +140,79 @@ def test_get_values_for_subjects_bulk(self):
134140
self.assertEqual(result[self.test_partner.id][self._var_name]["value"], 42)
135141
self.assertEqual(result[partner2.id][self._var_name]["value"], 100)
136142

143+
def _cache_value(self, variable_name, partner_id, value=7, source_type="external", company=None):
144+
self.env["spp.data.value"].create(
145+
{
146+
"company_id": (company or self.env.company).id,
147+
"variable_name": variable_name,
148+
"subject_model": "res.partner",
149+
"subject_id": partner_id,
150+
"period_key": "current",
151+
"value_json": {"value": value},
152+
"value_type": "number",
153+
"source_type": source_type,
154+
"recorded_at": fields.Datetime.now(),
155+
}
156+
)
157+
158+
def test_excludes_non_pullable_variable_values(self):
159+
"""A non-external (e.g. scoring/computed) variable's cached value must
160+
not be returned, even when '*'/None requests all values."""
161+
scoring_accessor = f"{self._var_name}_score"
162+
self.env["spp.cel.variable"].create(
163+
{
164+
"name": scoring_accessor,
165+
"cel_accessor": scoring_accessor,
166+
"source_type": "scoring",
167+
"value_type": "number",
168+
"state": "active",
169+
"applies_to": "both",
170+
}
171+
)
172+
self._cache_value(scoring_accessor, self.test_partner.id, value=99, source_type="scoring")
173+
174+
from ..services.variable_value_service import VariableValueService
175+
176+
result = VariableValueService(self.env).get_values_for_subject(
177+
self.test_partner.id, variable_names=None, period_key="current"
178+
)
179+
self.assertIn(self._var_name, result) # external-provider -> allowed
180+
self.assertNotIn(scoring_accessor, result) # scoring -> excluded
181+
182+
# Even an explicit request for the sensitive variable returns nothing.
183+
explicit = VariableValueService(self.env).get_values_for_subject(
184+
self.test_partner.id, variable_names=[scoring_accessor], period_key="current"
185+
)
186+
self.assertEqual(explicit, {})
187+
188+
def test_empty_allowlist_returns_nothing(self):
189+
"""When no variable is API-pullable, the endpoint returns nothing.
190+
191+
Locks in the fail-closed contract: an empty allowlist must produce
192+
`("variable_name", "in", [])` (match nothing), never match-all.
193+
"""
194+
from unittest.mock import patch
195+
196+
from ..services.variable_value_service import VariableValueService
197+
198+
service = VariableValueService(self.env)
199+
with patch.object(VariableValueService, "_data_api_pullable_accessors", return_value=[]):
200+
result = service.get_values_for_subject(self.test_partner.id, variable_names=None, period_key="current")
201+
self.assertEqual(result, {})
202+
203+
def test_company_isolation(self):
204+
"""Cached values from another company must not be returned."""
205+
other = self.env["res.company"].create({"name": f"Other {self._test_id}"})
206+
self._cache_value(self._var_name, self.test_partner.id, value=500, company=other)
207+
208+
from ..services.variable_value_service import VariableValueService
209+
210+
result = VariableValueService(self.env).get_values_for_subject(
211+
self.test_partner.id, variable_names=[self._var_name], period_key="current"
212+
)
213+
# Only the current company's value (42 from setUp), never the other's 500.
214+
self.assertEqual(result[self._var_name]["value"], 42)
215+
137216
def test_get_available_variables(self):
138217
"""Test listing available variables."""
139218
from ..services.variable_value_service import VariableValueService
@@ -328,6 +407,21 @@ def test_value_json_without_value_key(self):
328407
}
329408
)
330409

410+
# Back the cached value with a pullable (external-provider) variable so
411+
# the endpoint returns it (the allowlist excludes non-external vars).
412+
provider = self.env["spp.data.provider"].create({"name": f"P {unique_id}", "code": f"p_{unique_id}"})
413+
self.env["spp.cel.variable"].create(
414+
{
415+
"name": var_name,
416+
"cel_accessor": var_name,
417+
"source_type": "external",
418+
"external_provider_id": provider.id,
419+
"value_type": "number",
420+
"state": "active",
421+
"applies_to": "both",
422+
}
423+
)
424+
331425
# Create data value with non-standard value_json
332426
self.env["spp.data.value"].create(
333427
{
@@ -369,6 +463,20 @@ def test_value_json_complex_structure(self):
369463
}
370464
)
371465

466+
# Back the cached value with a pullable (external-provider) variable.
467+
provider = self.env["spp.data.provider"].create({"name": f"P {unique_id}", "code": f"p_{unique_id}"})
468+
self.env["spp.cel.variable"].create(
469+
{
470+
"name": var_name,
471+
"cel_accessor": var_name,
472+
"source_type": "external",
473+
"external_provider_id": provider.id,
474+
"value_type": "number",
475+
"state": "active",
476+
"applies_to": "both",
477+
}
478+
)
479+
372480
# Create data value with complex value_json
373481
complex_value = {
374482
"value": {"nested": {"data": "here"}, "count": 5},

0 commit comments

Comments
 (0)