Skip to content

Commit 425268b

Browse files
moving quidel signals to non-public access (#1261)
with integration tests! --------- Co-authored-by: Dmytro Trotsko <[email protected]>
1 parent 1042a5f commit 425268b

File tree

8 files changed

+171
-9
lines changed

8 files changed

+171
-9
lines changed

docs/api/covidcast-signals/quidel-inactive.md

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ grand_parent: COVIDcast Main Endpoint
1515
1. TOC
1616
{:toc}
1717

18+
## Accessibility: Delphi-internal only
1819

1920
## COVID-19 Tests
2021
These signals are still active. Documentation is available on the [Quidel page](quidel.md).

docs/api/covidcast-signals/quidel.md

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ grand_parent: COVIDcast Main Endpoint
1515
1. TOC
1616
{:toc}
1717

18+
## Accessibility: Delphi-internal only
19+
1820
## COVID-19 Tests
1921

2022
* **Earliest issue available:** July 29, 2020

docs/api/covidcast_signals.md

-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ dashboard](https://delphi.cmu.edu/covidcast/):
3636
| Early Indicators | COVID-Like Symptoms | [`fb-survey`](covidcast-signals/fb-survey.md) | `smoothed_wcli` |
3737
| Early Indicators | COVID-Like Symptoms in Community | [`fb-survey`](covidcast-signals/fb-survey.md) | `smoothed_whh_cmnty_cli` |
3838
| Early Indicators | COVID-Related Doctor Visits | [`doctor-visits`](covidcast-signals/doctor-visits.md) | `smoothed_adj_cli` |
39-
| Cases and Testing | COVID Antigen Test Positivity (Quidel) | [`quidel`](covidcast-signals/quidel.md) | `covid_ag_smoothed_pct_positive` |
4039
| Cases and Testing | COVID Cases | [`jhu-csse`](covidcast-signals/jhu-csse.md) | `confirmed_7dav_incidence_prop` |
4140
| Late Indicators | COVID Hospital Admissions | [`hhs`](covidcast-signals/hhs.md) | `confirmed_admissions_covid_1d_prop_7dav` |
4241
| Late Indicators | Deaths | [`jhu-csse`](covidcast-signals/jhu-csse.md) | `deaths_7dav_incidence_prop` |

integrations/server/test_covidcast_endpoints.py

+65-2
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,19 @@ def localSetUp(self):
2626
# reset the `covidcast_meta_cache` table (it should always have one row)
2727
self._db._cursor.execute('update covidcast_meta_cache set timestamp = 0, epidata = "[]"')
2828

29-
def _fetch(self, endpoint="/", is_compatibility=False, **params):
29+
cur = self._db._cursor
30+
# NOTE: we must specify the db schema "epidata" here because the cursor/connection are bound to schema "covid"
31+
cur.execute("TRUNCATE TABLE epidata.api_user")
32+
cur.execute("TRUNCATE TABLE epidata.user_role")
33+
cur.execute("TRUNCATE TABLE epidata.user_role_link")
34+
cur.execute("INSERT INTO epidata.api_user (api_key, email) VALUES ('quidel_key', 'quidel_email')")
35+
cur.execute("INSERT INTO epidata.user_role (name) VALUES ('quidel')")
36+
cur.execute(
37+
"INSERT INTO epidata.user_role_link (user_id, role_id) SELECT api_user.id, user_role.id FROM epidata.api_user JOIN epidata.user_role WHERE api_key='quidel_key' and user_role.name='quidel'"
38+
)
39+
cur.execute("INSERT INTO epidata.api_user (api_key, email) VALUES ('key', 'email')")
40+
41+
def _fetch(self, endpoint="/", is_compatibility=False, auth=AUTH, **params):
3042
# make the request
3143
if is_compatibility:
3244
url = BASE_URL_OLD
@@ -37,7 +49,7 @@ def _fetch(self, endpoint="/", is_compatibility=False, **params):
3749
params.setdefault("data_source", params.get("source"))
3850
else:
3951
url = f"{BASE_URL}{endpoint}"
40-
response = requests.get(url, params=params, auth=AUTH)
52+
response = requests.get(url, params=params, auth=auth)
4153
response.raise_for_status()
4254
return response.json()
4355

@@ -67,6 +79,28 @@ def test_basic(self):
6779
out = self._fetch("/", signal=first.signal_pair(), geo=first.geo_pair(), time="day:*")
6880
self.assertEqual(len(out["epidata"]), len(rows))
6981

82+
def test_basic_restricted_source(self):
83+
"""Request a signal from the / endpoint."""
84+
rows = [CovidcastTestRow.make_default_row(time_value=2020_04_01 + i, value=i, source="quidel") for i in range(10)]
85+
first = rows[0]
86+
self._insert_rows(rows)
87+
88+
with self.subTest("validation"):
89+
out = self._fetch("/")
90+
self.assertEqual(out["result"], -1)
91+
92+
with self.subTest("no_roles"):
93+
out = self._fetch("/", signal=first.signal_pair(), geo=first.geo_pair(), time="day:*")
94+
self.assertEqual(len(out["epidata"]), 0)
95+
96+
with self.subTest("no_api_key"):
97+
out = self._fetch("/", auth=None, signal=first.signal_pair(), geo=first.geo_pair(), time="day:*")
98+
self.assertEqual(len(out["epidata"]), 0)
99+
100+
with self.subTest("quidel_role"):
101+
out = self._fetch("/", auth=("epidata", "quidel_key"), signal=first.signal_pair(), geo=first.geo_pair(), time="day:*")
102+
self.assertEqual(len(out["epidata"]), len(rows))
103+
70104
def test_compatibility(self):
71105
"""Request at the /api.php endpoint."""
72106
rows = [CovidcastTestRow.make_default_row(source="src", signal="sig", time_value=2020_04_01 + i, value=i) for i in range(10)]
@@ -271,6 +305,35 @@ def test_meta(self):
271305
out = self._fetch("/meta", signal=f"{first.source}:X")
272306
self.assertEqual(len(out), 0)
273307

308+
def test_meta_restricted(self):
309+
"""Request 'restricted' signals from the /meta endpoint."""
310+
# NOTE: this method is nearly identical to ./test_covidcast_meta.py:test_restricted_sources()
311+
# ...except the self._fetch() methods are different, as is the format of those methods' outputs
312+
# (the other covidcast_meta endpoint uses APrinter, this one returns its own unadulterated json).
313+
# additionally, the sample data used here must match entries (that is, named sources and signals)
314+
# from covidcast_utils.model.data_sources (the `data_sources` variable from file
315+
# src/server/endpoints/covidcast_utils/model.py, which is created by the _load_data_sources() method
316+
# and fed by src/server/endpoints/covidcast_utils/db_sources.csv, but also surreptitiously augmened
317+
# by _load_data_signals() which attaches a list of signals to each source,
318+
# in turn fed by src/server/endpoints/covidcast_utils/db_signals.csv)
319+
320+
# insert data from two different sources, one restricted/protected (quidel), one not
321+
self._insert_rows([
322+
CovidcastTestRow.make_default_row(source="quidel", signal="raw_pct_negative"),
323+
CovidcastTestRow.make_default_row(source="hhs", signal="confirmed_admissions_covid_1d")
324+
])
325+
326+
# update metadata cache
327+
update_cache(args=None)
328+
329+
# verify unauthenticated (no api key) or unauthorized (user w/o privilege) only see metadata for one source
330+
self.assertEqual(len(self._fetch("/meta", auth=None)), 1)
331+
self.assertEqual(len(self._fetch("/meta", auth=AUTH)), 1)
332+
333+
# verify authorized user sees metadata for both sources
334+
qauth = ('epidata', 'quidel_key')
335+
self.assertEqual(len(self._fetch("/meta", auth=qauth)), 2)
336+
274337
def test_coverage(self):
275338
"""Request a signal from the /coverage endpoint."""
276339

integrations/server/test_covidcast_meta.py

+37-5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#first party
1111
from delphi_utils import Nans
12+
from delphi.epidata.acquisition.covidcast.test_utils import CovidcastBase, CovidcastTestRow
1213
from delphi.epidata.maintenance.covidcast_meta_cache_updater import main as update_cache
1314
import delphi.operations.secrets as secrets
1415

@@ -17,7 +18,7 @@
1718
AUTH = ('epidata', 'key')
1819

1920

20-
class CovidcastMetaTests(unittest.TestCase):
21+
class CovidcastMetaTests(CovidcastBase):
2122
"""Tests the `covidcast_meta` endpoint."""
2223

2324
src_sig_lookups = {
@@ -48,7 +49,7 @@ class CovidcastMetaTests(unittest.TestCase):
4849
%d, %d)
4950
'''
5051

51-
def setUp(self):
52+
def localSetUp(self):
5253
"""Perform per-test setup."""
5354

5455
# connect to the `epidata` database and clear the `covidcast` table
@@ -68,6 +69,17 @@ def setUp(self):
6869
# reset the `covidcast_meta_cache` table (it should always have one row)
6970
cur.execute('update covidcast_meta_cache set timestamp = 0, epidata = "[]"')
7071

72+
# NOTE: we must specify the db schema "epidata" here because the cursor/connection are bound to schema "covid"
73+
cur.execute("TRUNCATE TABLE epidata.api_user")
74+
cur.execute("TRUNCATE TABLE epidata.user_role")
75+
cur.execute("TRUNCATE TABLE epidata.user_role_link")
76+
cur.execute("INSERT INTO epidata.api_user (api_key, email) VALUES ('quidel_key', 'quidel_email')")
77+
cur.execute("INSERT INTO epidata.user_role (name) VALUES ('quidel')")
78+
cur.execute(
79+
"INSERT INTO epidata.user_role_link (user_id, role_id) SELECT api_user.id, user_role.id FROM epidata.api_user JOIN epidata.user_role WHERE api_key='quidel_key' and user_role.name='quidel'"
80+
)
81+
cur.execute("INSERT INTO epidata.api_user (api_key, email) VALUES ('key', 'email')")
82+
7183
# populate dimension tables
7284
for (src,sig) in self.src_sig_lookups:
7385
cur.execute('''
@@ -93,7 +105,7 @@ def setUp(self):
93105
secrets.db.epi = ('user', 'pass')
94106

95107

96-
def tearDown(self):
108+
def localTearDown(self):
97109
"""Perform per-test teardown."""
98110
self.cur.close()
99111
self.cnx.close()
@@ -138,10 +150,10 @@ def _get_id(self):
138150
return self.id_counter
139151

140152
@staticmethod
141-
def _fetch(**kwargs):
153+
def _fetch(auth=AUTH, **kwargs):
142154
params = kwargs.copy()
143155
params['endpoint'] = 'covidcast_meta'
144-
response = requests.get(BASE_URL, params=params, auth=AUTH)
156+
response = requests.get(BASE_URL, params=params, auth=auth)
145157
response.raise_for_status()
146158
return response.json()
147159

@@ -161,6 +173,26 @@ def test_round_trip(self):
161173
'message': 'success',
162174
})
163175

176+
def test_restricted_sources(self):
177+
# NOTE: this method is nearly identical to ./test_covidcast_endpoints.py:test_meta_restricted()
178+
179+
# insert data from two different sources, one restricted/protected (quidel), one not
180+
self._insert_rows([
181+
CovidcastTestRow.make_default_row(source="quidel"),
182+
CovidcastTestRow.make_default_row(source="not-quidel")
183+
])
184+
185+
# generate metadata cache
186+
update_cache(args=None)
187+
188+
# verify unauthenticated (no api key) or unauthorized (user w/o privilege) only see metadata for one source
189+
self.assertEqual(len(self._fetch(auth=None)['epidata']), 1)
190+
self.assertEqual(len(self._fetch(auth=AUTH)['epidata']), 1)
191+
192+
# verify authorized user sees metadata for both sources
193+
qauth = ('epidata', 'quidel_key')
194+
self.assertEqual(len(self._fetch(auth=qauth)['epidata']), 2)
195+
164196
def test_filter(self):
165197
"""Test filtering options some sample data."""
166198

src/server/_security.py

+16
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ def decorated_function(*args, **kwargs):
8282
return decorator_wrapper
8383

8484

85+
# key is data "source" name, value is role name required to access that source
86+
sources_protected_by_roles = {
87+
'quidel': 'quidel',
88+
# the following two entries are needed because
89+
# the covidcast endpoint uses this method
90+
# to allow using various different "source" name aliases:
91+
# delphi.epidata.server.endpoints.covidcast_utils.model.create_source_signal_alias_mapper()
92+
# which, for reference, is populated by the file:
93+
# src/server/endpoints/covidcast_utils/db_sources.csv
94+
'quidel-covid-ag': 'quidel',
95+
'quidel-flu': 'quidel',
96+
}
97+
# TODO(<insert gh issue link here>): source this info from a better place than a hardcoded dict:
98+
# maybe somewhere in the db? maybe in src/server/endpoints/covidcast_utils/db_sources.csv ?
99+
100+
85101
def update_key_last_time_used(user):
86102
if user:
87103
# update last usage for this user's api key to "now()"

src/server/endpoints/covidcast.py

+37
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@
3030
)
3131
from .._query import QueryBuilder, execute_query, run_query, parse_row, filter_fields
3232
from .._printer import create_printer, CSVPrinter
33+
from .._security import current_user, sources_protected_by_roles
3334
from .._validate import require_all
3435
from .._pandas import as_pandas, print_pandas
3536
from .covidcast_utils import compute_trend, compute_trends, compute_trend_value, CovidcastMetaEntry
3637
from ..utils import shift_day_value, day_to_time_value, time_value_to_iso, time_value_to_day, shift_week_value, time_value_to_week, guess_time_value_is_day, week_to_time_value, TimeValues
3738
from .covidcast_utils.model import TimeType, count_signal_time_types, data_sources, create_source_signal_alias_mapper
39+
from delphi.epidata.common.logger import get_structured_logger
3840

3941
# first argument is the endpoint name
4042
bp = Blueprint("covidcast", __name__)
@@ -43,9 +45,30 @@
4345
latest_table = "epimetric_latest_v"
4446
history_table = "epimetric_full_v"
4547

48+
def restrict_by_roles(source_signal_sets):
49+
# takes a list of SourceSignalSet objects
50+
# and returns only those from the list
51+
# that the current user is permitted to access.
52+
user = current_user
53+
allowed_source_signal_sets = []
54+
for src_sig_set in source_signal_sets:
55+
src = src_sig_set.source
56+
if src in sources_protected_by_roles:
57+
role = sources_protected_by_roles[src]
58+
if user and user.has_role(role):
59+
allowed_source_signal_sets.append(src_sig_set)
60+
else:
61+
# protected src and user does not have permission => leave it out of the srcsig sets
62+
get_structured_logger("covcast_endpt").warning("non-authZd request for restricted 'source'", api_key=(user and user.api_key), src=src)
63+
else:
64+
allowed_source_signal_sets.append(src_sig_set)
65+
return allowed_source_signal_sets
66+
67+
4668
@bp.route("/", methods=("GET", "POST"))
4769
def handle():
4870
source_signal_sets = parse_source_signal_sets()
71+
source_signal_sets = restrict_by_roles(source_signal_sets)
4972
source_signal_sets, alias_mapper = create_source_signal_alias_mapper(source_signal_sets)
5073
time_set = parse_time_set()
5174
geo_sets = parse_geo_sets()
@@ -102,6 +125,7 @@ def _verify_argument_time_type_matches(is_day_argument: bool, count_daily_signal
102125
def handle_trend():
103126
require_all(request, "window", "date")
104127
source_signal_sets = parse_source_signal_sets()
128+
source_signal_sets = restrict_by_roles(source_signal_sets)
105129
daily_signals, weekly_signals = count_signal_time_types(source_signal_sets)
106130
source_signal_sets, alias_mapper = create_source_signal_alias_mapper(source_signal_sets)
107131
geo_sets = parse_geo_sets()
@@ -157,6 +181,7 @@ def gen(rows):
157181
def handle_trendseries():
158182
require_all(request, "window")
159183
source_signal_sets = parse_source_signal_sets()
184+
source_signal_sets = restrict_by_roles(source_signal_sets)
160185
daily_signals, weekly_signals = count_signal_time_types(source_signal_sets)
161186
source_signal_sets, alias_mapper = create_source_signal_alias_mapper(source_signal_sets)
162187
geo_sets = parse_geo_sets()
@@ -405,8 +430,19 @@ def handle_meta():
405430
entry = by_signal.setdefault((row["data_source"], row["signal"]), [])
406431
entry.append(row)
407432

433+
user = current_user
408434
sources: List[Dict[str, Any]] = []
409435
for source in data_sources:
436+
src = source.db_source
437+
if src in sources_protected_by_roles:
438+
role = sources_protected_by_roles[src]
439+
if not (user and user.has_role(role)):
440+
# if this is a protected source
441+
# and the user doesnt have the allowed role
442+
# (or if we have no user)
443+
# then skip this source
444+
continue
445+
410446
meta_signals: List[Dict[str, Any]] = []
411447

412448
for signal in source.signals:
@@ -448,6 +484,7 @@ def handle_coverage():
448484
"""
449485

450486
source_signal_sets = parse_source_signal_sets()
487+
source_signal_sets = restrict_by_roles(source_signal_sets)
451488
daily_signals, weekly_signals = count_signal_time_types(source_signal_sets)
452489
source_signal_sets, alias_mapper = create_source_signal_alias_mapper(source_signal_sets)
453490

src/server/endpoints/covidcast_meta.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .._params import extract_strings
99
from .._printer import create_printer
1010
from .._query import filter_fields
11+
from .._security import current_user, sources_protected_by_roles
1112
from delphi.epidata.common.logger import get_structured_logger
1213

1314
bp = Blueprint("covidcast_meta", __name__)
@@ -71,17 +72,28 @@ def handle():
7172
age = metadata["age"]
7273
reported_age = max(0, min(age, standard_age) - age_margin)
7374

75+
user = current_user
76+
7477
def cache_entry_gen():
7578
for entry in metadata_list:
7679
if time_types and entry.get("time_type") not in time_types:
7780
continue
7881
if geo_types and entry.get("geo_type") not in geo_types:
7982
continue
83+
entry_source = entry.get("data_source")
84+
if entry_source in sources_protected_by_roles:
85+
role = sources_protected_by_roles[entry_source]
86+
if not (user and user.has_role(role)):
87+
# if this is a protected source
88+
# and the user doesnt have the allowed role
89+
# (or if we have no user)
90+
# then skip this source
91+
continue
8092
if not signals:
8193
yield entry
8294
for signal in signals:
8395
# match source and (signal or no signal or signal = *)
84-
if entry.get("data_source") == signal.source and (
96+
if entry_source == signal.source and (
8597
signal.signal == "*" or signal.signal == entry.get("signal")
8698
):
8799
yield entry

0 commit comments

Comments
 (0)