Skip to content

Commit e19406c

Browse files
Privacy strategy (#35)
* Privacy Strategy Final Commit By Sara Co-authored-by: sara-santana <saraamaralsantana@gmail.com>
1 parent d35514f commit e19406c

22 files changed

Lines changed: 1048 additions & 56 deletions

‎beacon/request/handlers.py‎

Lines changed: 165 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22
import asyncio
33
import copy
44
import logging
5+
import math
56
from typing import Dict, List, Tuple
7+
8+
import numpy as np
69
from aiohttp import web
710
from aiohttp.web_request import Request
811
from bson import json_util
912
from beacon import conf
13+
from beacon.db import client
14+
from pymongo import ReturnDocument
1015
import yaml
16+
import math
1117

1218
from beacon.request import ontologies
1319
from beacon.request.model import AlphanumericFilter, Granularity, RequestParams
@@ -60,62 +66,166 @@ async def wrapper(request: Request):
6066

6167
return wrapper
6268

69+
# support functions for the budget strategy
70+
71+
# update the budget of a specific individual for a user in the budget collection
72+
def update_individual_budget(user_id, individual_id, amount):
73+
try:
74+
budget_collection = client.beacon['budget']
75+
#LOG.debug(f"Updating budget for individual_id={individual_id} by amount={amount}")
76+
77+
# Find the document and update it, returning the updated document
78+
updated_document = budget_collection.find_one_and_update(
79+
{"individualId": individual_id, "userId": user_id},
80+
{"$inc": {"budget": -amount}},
81+
return_document=ReturnDocument.AFTER # Return the updated document
82+
)
83+
84+
return updated_document
85+
86+
except Exception as e:
87+
LOG.error(f"Error updating budget: {str(e)}")
88+
return None
89+
90+
def pvalue_strategy(access_token, records, qparams):
91+
helper = []
92+
total_cases = 0
93+
removed_individuals = []
94+
removed = False
95+
96+
for record in records:
97+
individual_ids = set()
98+
individuals_to_remove = set()
99+
100+
# step 4: compute the risk for that query: ri = -log(1 - Di)
101+
allele_frequency = record.get('alleleFrequency')
102+
N = client.beacon.get_collection('individuals').count_documents({}) # total number of individuals !! if user requestes dataset, N = individuals in that dataset
103+
Di = (1 - allele_frequency) ** (2 * N)
104+
ri = -(math.log10(1 - Di))
105+
LOG.debug(f"O CUSTO DESTA QUERY É ESTE = {ri}")
106+
107+
# fetch individualId from the biosample collection
108+
case_level_data = record.get('caseLevelData', [])
109+
for case in case_level_data:
110+
individual_id = case.get('biosampleId') # biosampleId = individualId
111+
individual_ids.add(individual_id)
112+
113+
for individualId in individual_ids:
114+
115+
search_criteria = {
116+
"userId": access_token,
117+
"individualId": individualId
118+
}
119+
120+
# Step 2: check if query has been asked before
121+
response_history = client.beacon['history'].find_one({"userId": access_token, "query": qparams.summary()})
122+
if response_history is not None:
123+
LOG.debug(f"Query was previously done by the same user")
124+
return response_history["response"], helper, total_cases, removed, removed_individuals # Return stored answer if query was asked before by the same user
125+
126+
# Step 3: check if there are records with bj > ri
127+
budget_info = client.beacon['budget'].find_one(search_criteria)
128+
if not budget_info:
129+
p_value = 0.5 # upper bound on test errors
130+
bj = -(math.log10(p_value)) # initial budget
131+
budget_info = {
132+
"userId": access_token,
133+
"individualId": individualId,
134+
"budget": bj
135+
}
136+
client.beacon['budget'].insert_one(budget_info)
137+
138+
# re-fetch the budget_info to ensure we have the latest data
139+
budget_info = client.beacon['budget'].find_one(search_criteria)
140+
141+
if budget_info and budget_info['budget'] < ri:
142+
143+
individuals_to_remove.add(individualId)
144+
else:
145+
if budget_info['budget'] >= ri:
146+
# Step 7: reduce their budgets by ri
147+
update_individual_budget(access_token, individualId, ri)
148+
budget_info = client.beacon['budget'].find_one(search_criteria)
149+
150+
if individuals_to_remove:
151+
# filter the individuals from the record
152+
removed = True
153+
removed_individuals = individuals_to_remove
154+
LOG.debug(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
155+
LOG.debug(f"Removed individuals: {list(individuals_to_remove)}") # signal to know which individuals have no more budget
156+
LOG.debug(f"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
157+
record['caseLevelData'] = [case for case in record['caseLevelData'] if case.get('biosampleId') not in individuals_to_remove]
158+
if record['caseLevelData'] != []:
159+
helper.append(record)
160+
else:
161+
helper.append(record)
162+
163+
total_cases += len(record['caseLevelData'])
164+
165+
return None, helper, total_cases, removed, removed_individuals
166+
167+
63168

64169
# handler with authentication & REMS
65170
# mostly from BioData.pt
66171
def generic_handler(db_fn, request=None):
67-
68-
async def wrapper(request:Request):
172+
173+
async def wrapper(request: Request):
69174
LOG.info("-- Generic handler --")
70-
175+
71176
# Get params
72177
entry_id = request.match_info.get('id', None)
73178
json_body = await request.json() if request.method == "POST" and request.has_body and request.can_read_body else {}
74-
qparams:RequestParams = RequestParams(**json_body).from_request(request)
75-
179+
qparams: RequestParams = RequestParams(**json_body).from_request(request)
180+
76181
LOG.debug(f"Query Params = {qparams}")
77-
182+
78183
LOG.debug(f"Headers = {request.headers}")
79-
184+
80185
access_token_header = request.headers.get('Authorization')
81186
access_token_cookies = request.cookies.get("Authorization")
82187
LOG.debug(f"Access token header = {access_token_header}")
83188
LOG.debug(f"Access token cookies = {access_token_cookies}")
84-
85-
# set access_token as the one we recieve in the header
189+
190+
registered = False
191+
public = False
192+
193+
# set access_token as the one we receive in the header
86194
# if not in header, get the one from cookies
87195
if access_token_header:
88196
access_token = access_token_header
89197
else:
90198
access_token = access_token_cookies
91-
199+
92200
# get specified datasets
93-
requested_datasets = qparams.query.request_parameters.get("datasets", None)
201+
requested_datasets = qparams.query.request_parameters.get("datasets", None)
94202
LOG.debug(f"requested_datasets = {requested_datasets}")
95-
203+
96204
# Start async task to request datasets from permissions server
97205
task_permissions = asyncio.create_task(get_accessible_datasets(access_token, requested_datasets))
98206

99207
# if no datasets were specified, use all in DB
100208
if requested_datasets is None:
101-
102209
# get list of all datasets in DB
103210
_, _, all_dataset_docs = get_datasets(None, RequestParams())
104-
all_dataset_ids = [ doc["id"] for doc in all_dataset_docs]
211+
all_dataset_ids = [doc["id"] for doc in all_dataset_docs]
105212
target_datasets = all_dataset_ids
106213
# else, query only the ones specified
107214
else:
108215
target_datasets = requested_datasets
109-
216+
110217
# TODO query all datasets in parallel
111218
tasks_dataset_queries = []
112219
# { dataset_id:(count, records) }
113220
datasets_query_results:Dict[str, Tuple[int,List[dict]]] = {}
114-
221+
115222
db_fn_submodule = str(db_fn.__module__).split(".")[-1]
116223
LOG.debug(f"db_fn submodule = {db_fn_submodule}")
117-
118-
224+
225+
# get response of permissions server
226+
accessible_datasets: List[str] = [] # array of dataset ids
227+
accessible_datasets, public, registered = await task_permissions
228+
119229
# TODO do this asynchronously
120230
for dataset_id in target_datasets:
121231
qparams_dataset = copy.deepcopy(qparams)
@@ -127,46 +237,66 @@ async def wrapper(request:Request):
127237
if db_fn_submodule == "g_variants":
128238
filter_dataset_id = {
129239
"id": "_info.datasetId",
130-
"value": dataset_id
240+
"value": dataset_id
131241
}
132242
else:
133243
filter_dataset_id = {
134-
"id": "datasetId",
244+
"id": "datasetId",
135245
"value": dataset_id
136246
}
137-
247+
138248
qparams_dataset.query.filters.append(filter_dataset_id)
139249
LOG.debug(f"Dataset Qparams = {qparams_dataset}")
140250
entity_schema, count, records = db_fn(entry_id, qparams_dataset)
141-
dataset_result = (count, list(records))
142-
datasets_query_results[dataset_id] = (dataset_result)
143-
144-
251+
252+
######################## P-VALUE STRATEGY ########################
253+
# apply the p-value strategy if user is authenticated but not registered and only if submodule is genomic variations
254+
count = 1
255+
LOG.debug(f"Is the user public? {public}")
256+
LOG.debug(f"Is the user registered {registered}")
257+
if not public and not registered and db_fn_submodule == "g_variants":
258+
history, records, total_cases, removed, removed_individuals = pvalue_strategy(access_token, records, qparams)
259+
dataset_result = (count, records, total_cases)
260+
datasets_query_results[dataset_id] = (dataset_result)
261+
262+
if history is not None:
263+
LOG.debug(f"Query was previously made by the same user")
264+
return await json_stream(request, history)
265+
145266
LOG.debug(f"schema = {entity_schema}")
146-
147-
# get response of permissions server
148-
accessible_datasets:List[str] = [] # array of dataset ids
149-
accessible_datasets = await task_permissions
150267

151268
# get the max authorized granularity
152269
requested_granularity = qparams.query.requested_granularity
153270
max_granularity = Granularity(conf.max_beacon_granularity)
154271
response_granularity = Granularity.get_lower(requested_granularity, max_granularity)
155-
272+
156273
# build response
157-
158-
response = build_generic_response(
274+
response, store = build_generic_response(
159275
results_by_dataset=datasets_query_results,
160276
accessible_datasets=accessible_datasets,
161277
granularity=response_granularity,
162278
qparams=qparams,
163-
entity_schema=entity_schema
279+
entity_schema=entity_schema,
280+
registered=registered,
281+
public=public
164282
)
283+
LOG.debug(f"Will the response be stored? {store}")
165284

166-
return await json_stream(request, response)
167-
285+
286+
document = {
287+
"userId": access_token,
288+
"query": qparams.summary(),
289+
"response": response
290+
}
291+
292+
if store:
293+
client.beacon['history'].insert_one(document)
168294

295+
296+
return await json_stream(request, removed_individuals)
297+
169298
return wrapper
299+
170300

171301

172302

‎beacon/response/build_response.py‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,41 +39,60 @@ def build_response_summary(exists, num_total_results):
3939
# receives results(count & records) of each queried dataset, authorized datasets, and granularity of results
4040
def build_generic_response(
4141
results_by_dataset:Dict[str,Tuple[int,list]], accessible_datasets:List[str], granularity:Granularity,
42-
qparams, entity_schema):
43-
42+
qparams, entity_schema, registered:bool, public:bool):
43+
44+
# flag to check if we need to keep the query and result in database (if user is registered and results include non accessible datasets)
45+
store = False
46+
4447
# iterate over all results to get:
4548
# total count
4649
# response by dataset
50+
4751
num_total_results = 0
4852
response_list:List[Dict] = []
4953
for dataset_id in results_by_dataset:
54+
# if user is not authenticated, they cannot see aggregated data from datasets that are not public, so this will prevent
55+
# the non public datasets to be appended to non authenticated users responses
56+
if public and dataset_id not in accessible_datasets:
57+
continue
58+
5059
num_dataset_results = results_by_dataset[dataset_id][0]
5160
dataset_results = results_by_dataset[dataset_id][1]
61+
total_cases = results_by_dataset[dataset_id][2]
5262
num_total_results += num_dataset_results
5363

5464
dataset_response = {
5565
"id": dataset_id,
5666
"exists": num_dataset_results > 0,
5767
"setType": "dataset",
5868
"results": dataset_results,
59-
"resultsCount": num_dataset_results
69+
"resultsCount": total_cases
6070
}
6171

6272
# if dataset is not authorized, erase the records part
63-
if dataset_id not in accessible_datasets:
73+
if dataset_id not in accessible_datasets or not registered:
6474
dataset_response["results"] = []
6575

6676
response_list.append(dataset_response)
77+
78+
if dataset_id not in accessible_datasets and not registered and not public:
79+
store = True
6780

68-
beacon_response = {
69-
'meta': build_meta(qparams, entity_schema, granularity),
70-
'responseSummary': build_response_summary(num_total_results > 0, num_total_results),
71-
'beaconHandovers': conf.beacon_handovers,
72-
'response': {
73-
'resultSets': response_list
81+
beacon_response = []
82+
83+
if not dataset_results == []:
84+
85+
beacon_response = {
86+
'meta': build_meta(qparams, entity_schema, granularity),
87+
'responseSummary': build_response_summary(num_total_results > 0, num_total_results),
88+
'beaconHandovers': conf.beacon_handovers,
89+
'response': {
90+
'resultSets': response_list
91+
}
7492
}
75-
}
76-
return beacon_response
93+
94+
95+
return beacon_response, store
7796

7897

7998
def build_response_by_dataset(data, response_dict, num_total_results, qparams, func):

0 commit comments

Comments
 (0)