Skip to content

Commit bd4b01b

Browse files
authored
Merge pull request #830 from hubmapconsortium/karlburke/LockPubEntitiesFromUpdate
Use per-entity criteria to lock public or published entities from update
2 parents 48c3d2f + f12f91e commit bd4b01b

File tree

7 files changed

+164
-45
lines changed

7 files changed

+164
-45
lines changed

src/app.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@
8989
MEMCACHED_MODE = False
9090
MEMCACHED_PREFIX = 'NONE'
9191

92+
# Read the secret key which may be submitted in HTTP Request Headers to override the lockout of
93+
# updates to entities with characteristics prohibiting their modification.
94+
LOCKED_ENTITY_UPDATE_OVERRIDE_KEY = app.config['LOCKED_ENTITY_UPDATE_OVERRIDE_KEY']
95+
9296
# Suppress InsecureRequestWarning warning when requesting status on https with ssl cert verify disabled
9397
requests.packages.urllib3.disable_warnings(category = InsecureRequestWarning)
9498

@@ -1363,8 +1367,29 @@ def update_entity(id):
13631367
# Normalize user provided entity_type
13641368
normalized_entity_type = schema_manager.normalize_entity_type(entity_dict['entity_type'])
13651369

1366-
# Note, we don't support entity level validators on entity update via PUT
1367-
# Only entity create via POST is supported at the entity level
1370+
# Execute entity level validator defined in schema yaml before entity modification.
1371+
lockout_overridden = False
1372+
try:
1373+
schema_manager.execute_entity_level_validator(validator_type='before_entity_update_validator'
1374+
, normalized_entity_type=normalized_entity_type
1375+
, request=request
1376+
, existing_entity_dict=entity_dict)
1377+
except schema_errors.MissingApplicationHeaderException as e:
1378+
bad_request_error(e)
1379+
except schema_errors.InvalidApplicationHeaderException as e:
1380+
bad_request_error(e)
1381+
except schema_errors.LockedEntityUpdateException as leue:
1382+
# HTTP header names are case-insensitive, and request.headers.get() returns None if the header doesn't exist
1383+
locked_entity_update_header = request.headers.get(SchemaConstants.LOCKED_ENTITY_UPDATE_HEADER)
1384+
if locked_entity_update_header and (LOCKED_ENTITY_UPDATE_OVERRIDE_KEY == locked_entity_update_header):
1385+
lockout_overridden = True
1386+
logger.info(f"For {entity_dict['entity_type']} {entity_dict['uuid']}"
1387+
f" update prohibited due to {str(leue)},"
1388+
f" but being overridden by valid {SchemaConstants.LOCKED_ENTITY_UPDATE_HEADER} in request.")
1389+
else:
1390+
forbidden_error(leue)
1391+
except Exception as e:
1392+
internal_server_error(e)
13681393

13691394
# Validate request json against the yaml schema
13701395
# Pass in the entity_dict for missing required key check, this is different from creating new entity
@@ -1383,6 +1408,9 @@ def update_entity(id):
13831408
ValueError) as e:
13841409
bad_request_error(e)
13851410

1411+
# Proceed with per-entity updates after passing any entity-level or property-level validations which
1412+
# would have locked out updates.
1413+
#
13861414
# Sample, Dataset, and Upload: additional validation, update entity, after_update_trigger
13871415
# Collection and Donor: update entity
13881416
if normalized_entity_type == 'Sample':
@@ -1467,13 +1495,6 @@ def update_entity(id):
14671495
if has_dataset_uuids_to_link or has_dataset_uuids_to_unlink or has_updated_status:
14681496
after_update(normalized_entity_type, user_token, merged_updated_dict)
14691497
elif schema_manager.entity_type_instanceof(normalized_entity_type, 'Collection'):
1470-
entity_visibility = _get_entity_visibility( normalized_entity_type=normalized_entity_type
1471-
,entity_dict=entity_dict)
1472-
# Prohibit update of an existing Collection if it meets criteria of being visible to public e.g. has DOI.
1473-
if entity_visibility == DataVisibilityEnum.PUBLIC:
1474-
logger.info(f"Attempt to update {normalized_entity_type} with id={id} which has visibility {entity_visibility}.")
1475-
bad_request_error(f"Cannot update {normalized_entity_type} due '{entity_visibility.value}' visibility.")
1476-
14771498
# Generate 'before_update_trigger' data and update the entity details in Neo4j
14781499
merged_updated_dict = update_entity_details(request, normalized_entity_type, user_token, json_data_dict, entity_dict)
14791500

@@ -1539,7 +1560,8 @@ def update_entity(id):
15391560
# Do not return the updated dict to avoid computing overhead - 7/14/2023 by Zhou
15401561
# return jsonify(normalized_complete_dict)
15411562

1542-
return jsonify({'message': f"{normalized_entity_type} of {id} has been updated"})
1563+
override_msg = 'Lockout overridden. ' if lockout_overridden else ''
1564+
return jsonify({'message': f"{override_msg}{normalized_entity_type} of {id} has been updated"})
15431565

15441566

15451567
"""

src/instance/app.cfg.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ NEO4J_URI = 'bolt://hubmap-neo4j-localhost:7687'
2626
NEO4J_USERNAME = 'neo4j'
2727
NEO4J_PASSWORD = '123'
2828

29+
# Secret value presented with the request header value named by
30+
# SchemaConstants.LOCKED_ENTITY_UPDATE_HEADER, expected to be off the form
31+
# X-HuBMAP-Update-Override: <LOCKED_ENTITY_UPDATE_OVERRIDE_KEY value which follows>
32+
LOCKED_ENTITY_UPDATE_OVERRIDE_KEY = 'set during deployment'
33+
2934
# Set MEMCACHED_MODE to False to disable the caching for local development
3035
MEMCACHED_MODE = True
3136
MEMCACHED_SERVER = 'host:11211'

src/schema/provenance_schema.yaml

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
# - trigger types: before_create_trigger|after_create_trigger|before_update_trigger|after_update_trigger|on_read_trigger|on_index_trigger, one property can have none (default) or more than one triggers
1111
# - updated_peripherally: a temporary measure to correctly handle any attributes which are potentially updated by multiple triggers
1212

13-
# Entity level validator:
14-
# - types: before_entity_create_validator, a single validation method needed for creating or updating the entity
13+
# Entity level validators:
14+
# - types: before_entity_create_validator - validation method needed for creating an entity.
15+
# before_entity_update_validator - validation method needed for updating an entity.
1516

1617
# Property level validators:
1718
# - types: before_property_create_validators|before_property_update_validators, a list of validation methods
@@ -192,6 +193,9 @@ shared_entity_properties: &shared_entity_properties
192193
ENTITIES:
193194
############################################# Collection #############################################
194195
Collection:
196+
before_entity_update_validator:
197+
# Halt modification of entities which are "locked", such as a Dataset with status == 'Published'
198+
- validate_entity_not_locked_before_update
195199
excluded_properties_from_public_response:
196200
- datasets:
197201
- lab_dataset_id
@@ -304,6 +308,9 @@ ENTITIES:
304308
Dataset:
305309
# Only allowed applications can create new Dataset via POST
306310
before_entity_create_validator: validate_application_header_before_entity_create
311+
before_entity_update_validator:
312+
# Halt modification of entities which are "locked", such as a Dataset with status == 'Published'
313+
- validate_entity_not_locked_before_update
307314
# Dataset can be either derivation source or target
308315
excluded_properties_from_public_response:
309316
- lab_dataset_id
@@ -659,6 +666,9 @@ ENTITIES:
659666
superclass: Dataset
660667
# Only allowed applications can create new Publication via POST
661668
before_entity_create_validator: validate_application_header_before_entity_create
669+
before_entity_update_validator:
670+
# Halt modification of entities which are "locked", such as a Dataset with status == 'Published'
671+
- validate_entity_not_locked_before_update
662672
# Publications can be either derivation source or target
663673
derivation:
664674
source: true
@@ -763,6 +773,9 @@ ENTITIES:
763773
- lab_donor_id
764774
- submission_id
765775
- label
776+
before_entity_update_validator:
777+
# Halt modification of entities which are "locked", such as a Dataset with status == 'Published'
778+
- validate_entity_not_locked_before_update
766779
properties:
767780
<<: *shared_properties
768781
<<: *shared_entity_properties
@@ -896,6 +909,9 @@ ENTITIES:
896909
- lab_id
897910
# Both Sample and Donor ancestors of a Sample must have these fields removed
898911
- submission_id
912+
before_entity_update_validator:
913+
# Halt modification of entities which are "locked", such as a Dataset with status == 'Published'
914+
- validate_entity_not_locked_before_update
899915
properties:
900916
<<: *shared_properties
901917
<<: *shared_entity_properties
@@ -1148,7 +1164,10 @@ ENTITIES:
11481164
Upload:
11491165
# Only allowed applications can create new Upload via POST
11501166
before_entity_create_validator: validate_application_header_before_entity_create
1151-
# Upload requires an ancestor of Lab, and and has no allowed decesndents
1167+
# No before_entity_update_validator needed for Upload because the entity is
1168+
# always considered "non-public", and therefore not blocked from update/PUT.
1169+
#
1170+
# Upload requires a Lab entity as an ancestor, and has no allowed descendants
11521171
derivation:
11531172
source: false
11541173
target: false # Set to false since the schema doesn't handle Lab currently
@@ -1296,5 +1315,8 @@ ENTITIES:
12961315
derivation:
12971316
source: false
12981317
target: false
1318+
before_entity_update_validator:
1319+
# Halt modification of entities which are "locked", such as a Dataset with status == 'Published'
1320+
- validate_entity_not_locked_before_update
12991321
properties:
13001322
<<: *shared_collection_properties

src/schema/schema_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class SchemaConstants(object):
77
COMPONENT_DATASET = 'component-dataset'
88
INGEST_PIPELINE_APP = 'ingest-pipeline'
99
HUBMAP_APP_HEADER = 'X-Hubmap-Application'
10+
LOCKED_ENTITY_UPDATE_HEADER = 'X-HuBMAP-Update-Override'
1011
INTERNAL_TRIGGER = 'X-Internal-Trigger'
1112
DATASET_STATUS_PUBLISHED = 'published'
1213

src/schema/schema_errors.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@ class MissingApplicationHeaderException(Exception):
3939
pass
4040

4141
class InvalidApplicationHeaderException(Exception):
42+
pass
43+
44+
class LockedEntityUpdateException(Exception):
4245
pass

src/schema/schema_manager.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,13 +1166,15 @@ def validate_json_data_against_schema(json_data_dict, normalized_entity_type, ex
11661166
Parameters
11671167
----------
11681168
validator_type : str
1169-
One of the validator types: before_entity_create_validator
1169+
One of the validator types recognized by the validate_entity_level_validator_type() method.
11701170
normalized_entity_type : str
11711171
One of the normalized entity types defined in the schema yaml: Donor, Sample, Dataset, Upload, Upload, Publication
11721172
request: Flask request object
11731173
The instance of Flask request passed in from application request
1174+
existing_entity_dict : dict
1175+
The dictionary for an entity, retrieved from Neo4j, for use during update/PUT validations
11741176
"""
1175-
def execute_entity_level_validator(validator_type, normalized_entity_type, request):
1177+
def execute_entity_level_validator(validator_type, normalized_entity_type, request, existing_entity_dict=None):
11761178
global _schema
11771179

11781180
# A bit validation
@@ -1183,23 +1185,41 @@ def execute_entity_level_validator(validator_type, normalized_entity_type, reque
11831185

11841186
for key in entity:
11851187
if validator_type == key:
1186-
validator_method_name = entity[validator_type]
1188+
if isinstance(entity[validator_type], str):
1189+
validator_method_names = [entity[validator_type]]
1190+
else:
1191+
# default to expecting a list when not a str
1192+
validator_method_names = entity[validator_type]
11871193

1188-
try:
1189-
# Get the target validator method defined in the schema_validators.py module
1190-
validator_method_to_call = getattr(schema_validators, validator_method_name)
1191-
1192-
logger.info(f"To run {validator_type}: {validator_method_name} defined for entity {normalized_entity_type}")
1193-
1194-
validator_method_to_call(normalized_entity_type, request)
1195-
except schema_errors.MissingApplicationHeaderException as e:
1196-
raise schema_errors.MissingApplicationHeaderException(e)
1197-
except schema_errors.InvalidApplicationHeaderException as e:
1198-
raise schema_errors.InvalidApplicationHeaderException(e)
1199-
except Exception as e:
1200-
msg = f"Failed to call the {validator_type} method: {validator_method_name} defined for entity {normalized_entity_type}"
1201-
# Log the full stack trace, prepend a line with our message
1202-
logger.exception(msg)
1194+
for validator_method_name in validator_method_names:
1195+
try:
1196+
# Get the target validator method defined in the schema_validators.py module
1197+
validator_method_to_call = getattr(schema_validators, validator_method_name)
1198+
1199+
logger.info(f"To run {validator_type}: {validator_method_name} defined for entity {normalized_entity_type}")
1200+
1201+
# Create a dictionary to hold data need by any entity validator, which must be populated
1202+
# with validator specific requirements when the method to be called is determined.
1203+
options_dict = {}
1204+
if existing_entity_dict is None:
1205+
# Execute the entity-level validation for create/POST
1206+
options_dict['http_request'] = request
1207+
validator_method_to_call(options_dict)
1208+
else:
1209+
# Execute the entity-level validation for update/PUT
1210+
options_dict['existing_entity_dict']= existing_entity_dict
1211+
validator_method_to_call(options_dict)
1212+
except schema_errors.MissingApplicationHeaderException as e:
1213+
raise schema_errors.MissingApplicationHeaderException(e)
1214+
except schema_errors.InvalidApplicationHeaderException as e:
1215+
raise schema_errors.InvalidApplicationHeaderException(e)
1216+
except schema_errors.LockedEntityUpdateException as leue:
1217+
raise leue
1218+
except Exception as e:
1219+
msg = f"Failed to call the {validator_type} method: {validator_method_name} defined for entity {normalized_entity_type}"
1220+
# Log the full stack trace, prepend a line with our message
1221+
logger.exception(msg)
1222+
raise e
12031223

12041224

12051225
"""
@@ -1360,10 +1380,10 @@ def validate_trigger_type(trigger_type:TriggerTypeEnum):
13601380
Parameters
13611381
----------
13621382
validator_type : str
1363-
One of the validator types: before_entity_create_validator
1383+
Name of an entity-level validator type, which must be listed in accepted_validator_types and found in this schema manager module.
13641384
"""
13651385
def validate_entity_level_validator_type(validator_type):
1366-
accepted_validator_types = ['before_entity_create_validator']
1386+
accepted_validator_types = ['before_entity_create_validator', 'before_entity_update_validator']
13671387
separator = ', '
13681388

13691389
if validator_type.lower() not in accepted_validator_types:

0 commit comments

Comments
 (0)