implemented PUT-request validation. It validates the user's authority, as well as the respective PUT data. Both, POST-request and PUT-request, of shipcalls are now input-validated.

This commit is contained in:
Max Metz 2024-05-27 15:00:00 +02:00
parent 7c8cd3763a
commit 95c771b7d1
6 changed files with 272 additions and 17 deletions

View File

@ -6,10 +6,12 @@ from .. import impl
from ..services.auth_guard import auth_guard, check_jwt from ..services.auth_guard import auth_guard, check_jwt
from BreCal.validators.input_validation import validate_posted_shipcall_data, check_if_user_is_bsmd_type from BreCal.validators.input_validation import validate_posted_shipcall_data, check_if_user_is_bsmd_type
from BreCal.validators.input_validation_shipcall import InputValidationShipcall from BreCal.validators.input_validation_shipcall import InputValidationShipcall
from BreCal.database.sql_handler import execute_sql_query_standalone
import logging import logging
import json import json
import traceback import traceback
import werkzeug
bp = Blueprint('shipcalls', __name__) bp = Blueprint('shipcalls', __name__)
@ -47,8 +49,7 @@ def PostShipcalls():
# read the user data from the JWT token (set when login is performed) # read the user data from the JWT token (set when login is performed)
user_data = check_jwt() user_data = check_jwt()
# validate the posted shipcall data # validate the posted shipcall data & the user's authority
# validate_posted_shipcall_data(user_data, loadedModel, content)
InputValidationShipcall.evaluate_post_data(user_data, loadedModel, content) InputValidationShipcall.evaluate_post_data(user_data, loadedModel, content)
except ValidationError as ex: except ValidationError as ex:
@ -76,17 +77,18 @@ def PutShipcalls():
# read the user data from the JWT token (set when login is performed) # read the user data from the JWT token (set when login is performed)
user_data = check_jwt() user_data = check_jwt()
# check, whether the user belongs to a participant, which is of type ParticipantType.BSMD # validate the PUT shipcall data and the user's authority
# as ParticipantType is an IntFlag, a user belonging to multiple groups is properly evaluated. InputValidationShipcall.evaluate_put_data(user_data, loadedModel, content)
is_bsmd = check_if_user_is_bsmd_type(user_data)
if not is_bsmd:
raise ValidationError(f"current user does not belong to BSMD. Cannot post shipcalls. Found user data: {user_data}")
# InputValidationShipcall.evaluate_put_data(user_data, loadedModel, content)
except ValidationError as ex: except ValidationError as ex:
logging.error(ex) logging.error(ex)
print(ex) print(ex)
return json.dumps({"message":f"bad format. \nError Messages: {ex.messages}. \nValid Data: {ex.valid_data}"}), 400 return json.dumps({"message":f"bad format. \nError Messages: {ex.messages}. \nValid Data: {ex.valid_data}"}), 400
except werkzeug.exceptions.Forbidden as ex:
logging.error(ex)
print(ex)
return json.dumps({"message":ex.description}), 403
except Exception as ex: except Exception as ex:
logging.error(ex) logging.error(ex)

View File

@ -1,9 +1,11 @@
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import pydapper
import datetime import datetime
import typing import typing
from BreCal.schemas.model import Shipcall, Ship, Participant, Berth, User, Times, ShipcallParticipantMap from BreCal.schemas.model import Shipcall, Ship, Participant, Berth, User, Times, ShipcallParticipantMap
from BreCal.database.enums import ParticipantType from BreCal.database.enums import ParticipantType
from BreCal.local_db import getPoolConnection
def pandas_series_to_data_model(): def pandas_series_to_data_model():
return return
@ -51,6 +53,29 @@ def get_synchronous_shipcall_times_standalone(query_time:pd.Timestamp, all_df_ti
counts = sum(time_deltas_filtered) # int counts = sum(time_deltas_filtered) # int
return counts return counts
def execute_sql_query_standalone(query, param, pooledConnection=None):
"""
execute an arbitrary query with a set of parameters, return the output and convert it to a list.
when the pooled connection is rebuilt, it will be closed at the end of the function.
"""
rebuild_pooled_connection = pooledConnection is None
if rebuild_pooled_connection:
pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection)
# participant_query = "SELECT participant_id, type FROM shipcall_participant_map WHERE shipcall_id=?shipcall_id?";
# creates a generator
schemas = commands.query(query, model=dict, param=param, buffered=False)
# creates a list of results from the generator
schemas = [schema for schema in schemas]
if rebuild_pooled_connection:
pooledConnection.close()
return schemas
class SQLHandler(): class SQLHandler():
""" """
An object that reads SQL queries from the sql_connection and stores it in pandas DataFrames. The object can read all available tables An object that reads SQL queries from the sql_connection and stores it in pandas DataFrames. The object can read all available tables
@ -66,6 +91,15 @@ class SQLHandler():
if read_all: if read_all:
self.read_all(self.all_schemas) self.read_all(self.all_schemas)
def execute_sql_query(self, sql_connection, query, param):
"""
this method is best used in combination with a python context-manager, such as:
with mysql.connector.connect(**mysql_connection_data) as sql_connection:
schema = sql_handler.execute_sql_query(sql_connection, query)
"""
schemas = execute_sql_query_standalone(query, param, pooledConnection=sql_connection)
return schemas
def get_all_schemas_from_mysql(self): def get_all_schemas_from_mysql(self):
with self.sql_connection.cursor(buffered=True) as cursor: with self.sql_connection.cursor(buffered=True) as cursor:
cursor.execute("SHOW TABLES") cursor.execute("SHOW TABLES")

View File

@ -48,6 +48,12 @@ def GetShipcalls(options):
def PostShipcalls(schemaModel): def PostShipcalls(schemaModel):
""" """
This function *executes* a post-request for shipcalls. The function is accessible as part of an API route.
The common sequence is:
a) issue a request to the Flask API
b) BreCal.api.shipcalls.PostShipcalls, to verify the incoming request (which includes an authentification guard)
c) BreCal.impl.shipcalls.PostShipcalls, to execute the incoming request
:param schemaModel: The deserialized dict of the request :param schemaModel: The deserialized dict of the request
e.g., e.g.,
@ -57,7 +63,6 @@ def PostShipcalls(schemaModel):
'pier_side': False, 'bunkering': True, 'recommended_tugs': 2, 'type_value': 1, 'evaluation_value': 0} 'pier_side': False, 'bunkering': True, 'recommended_tugs': 2, 'type_value': 1, 'evaluation_value': 0}
} }
""" """
# TODO: Validate the upload data
# This creates a *new* entry # This creates a *new* entry
try: try:
@ -180,6 +185,8 @@ def PutShipcalls(schemaModel):
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
user_data = check_jwt()
# test if object to update is found # test if object to update is found
sentinel = object() sentinel = object()
@ -263,7 +270,6 @@ def PutShipcalls(schemaModel):
# save history data # save history data
# TODO: set ETA properly # TODO: set ETA properly
user_data = check_jwt()
# query = create_sql_query_history_put() # query = create_sql_query_history_put()
query = "INSERT INTO history (participant_id, shipcall_id, user_id, timestamp, eta, type, operation) VALUES (?pid?, ?scid?, ?uid?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 2)" query = "INSERT INTO history (participant_id, shipcall_id, user_id, timestamp, eta, type, operation) VALUES (?pid?, ?scid?, ?uid?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1, 2)"
commands.execute(query, {"scid" : schemaModel["id"], "pid" : user_data["participant_id"], "uid" : user_data["id"]}) commands.execute(query, {"scid" : schemaModel["id"], "pid" : user_data["participant_id"], "uid" : user_data["id"]})

View File

@ -1,3 +1,4 @@
import typing
import json import json
import datetime import datetime
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@ -11,7 +12,8 @@ from BreCal.impl.berths import GetBerths
from BreCal.database.enums import ParticipantType, ParticipantFlag from BreCal.database.enums import ParticipantType, ParticipantFlag
from BreCal.validators.input_validation_utils import check_if_user_is_bsmd_type, check_if_ship_id_is_valid, check_if_berth_id_is_valid, check_if_participant_ids_are_valid, check_if_participant_ids_and_types_are_valid, check_if_string_has_special_characters, get_shipcall_id_dictionary, get_participant_type_from_user_data, check_if_int_is_valid_flag from BreCal.validators.input_validation_utils import check_if_user_is_bsmd_type, check_if_ship_id_is_valid, check_if_berth_id_is_valid, check_if_participant_ids_are_valid, check_if_participant_ids_and_types_are_valid, check_if_string_has_special_characters, get_shipcall_id_dictionary, get_participant_type_from_user_data, check_if_int_is_valid_flag
from BreCal.database.sql_handler import execute_sql_query_standalone
import werkzeug
class InputValidationShipcall(): class InputValidationShipcall():
""" """
@ -67,18 +69,20 @@ class InputValidationShipcall():
5. a canceled shipcall may not be changed 5. a canceled shipcall may not be changed
""" """
# check for permission (only BSMD-type participants) # check for permission (only BSMD-type participants)
# #TODO: are both, bsmd and agency, user types accepted?
InputValidationShipcall.check_user_is_bsmd_type(user_data) InputValidationShipcall.check_user_is_bsmd_type(user_data)
# check, whether an agency is listed in the shipcall-participant-map # check, whether an agency is listed in the shipcall-participant-map
# InputValidationShipcall.check_agency_in_shipcall_participant_map() # args? InputValidationShipcall.check_agency_in_shipcall_participant_map(user_data, loadedModel, content)
# the ID field is required, all missing fields will be ignored in the update # the ID field is required, all missing fields will be ignored in the update
InputValidationShipcall.check_required_fields_of_put_request(content) InputValidationShipcall.check_required_fields_of_put_request(content)
# check for reasonable values in the shipcall fields and checks for forbidden keys. Note: 'canceled' is allowed in PUT-requests. # check for reasonable values in the shipcall fields and checks for forbidden keys.
InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"]) InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"])
# a canceled shipcall cannot be selected # a canceled shipcall cannot be selected
# Note: 'canceled' is allowed in PUT-requests, if it is not already set (which is checked by InputValidationShipcall.check_shipcall_is_cancel)
InputValidationShipcall.check_shipcall_is_canceled(loadedModel, content) InputValidationShipcall.check_shipcall_is_canceled(loadedModel, content)
return return
@ -113,7 +117,65 @@ class InputValidationShipcall():
return return
@staticmethod @staticmethod
def check_agency_in_shipcall_participant_map(): # args? def check_agency_in_shipcall_participant_map(user_data:dict, loadedModel:dict, content:dict, spm_shipcall_data:typing.Optional[list]=None):
"""
When the request is issued by a user of type 'AGENCY', there must be special caution. Agency users cannot self-assign as participants
of a shipcall. Further, when no AGENCY is assigned to the shipcall, a PUT-request is not feasible. In those cases, the
BSMD must first assign an agency, before a PUT-request can assign further participants.
Upon violation, this method issues 'Forbidden'-Exceptions with HTTP status code 403. There are four reasons for violations:
a) an agency tries to self-assign for a shipcall
b) there is no assigned agency for the current shipcall
c) an agency is assigned, but the current agency-user belongs to a different participant_id
d) the user must be of ParticipantType BSMD or AGENCY
args:
spm_shipcall_data:
a list of entries obtained from the ShipcallParticipantMap. These are deserialized dictionaries.
e.g., [{'participant_id': 136, 'type': 8}, ]
"""
if spm_shipcall_data is None:
# read the ShipcallParticipantMap entry of the current shipcall_id. This is used within the input validation of a PUT request
spm_shipcall_data = execute_sql_query_standalone(
query = "SELECT participant_id, type FROM shipcall_participant_map WHERE shipcall_id=?shipcall_id?",
param={"shipcall_id":loadedModel["id"]},
pooledConnection=None
)
# which role should be set by the PUT request? If the agency is about to be set, an error will be created
# read the user data from the JWT token (set when login is performed)
user_type = get_participant_type_from_user_data(user_data) # decode JWT -> get 'type' value
# select the matching entries from the ShipcallParticipantMap
agency_entries = [spm_entry for spm_entry in spm_shipcall_data if int(spm_entry.get("type"))==int(ParticipantType.AGENCY)] # find all entries of type AGENCY (there should be at max. 1)
# when the request stems from an AGENCY user, and the user wants to PUT an AGENCY role, the request should fail
# boolean: check, whether any of the assigned participants is of type AGENCY
types = [participant.get("type") for participant in loadedModel["participants"]] # readout the participants from the loadedModel, which shall be assigned by the PUT request
any_type_is_agency = any([int(type_) == int(ParticipantType.AGENCY) for type_ in types]) # check, whether *any* of the participants is an agency
if not (int(user_type) in [int(ParticipantType.AGENCY), int(ParticipantType.BSMD)]):
# user not AGENCY or BSMD
raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by AGENCY or BSMD users.") # Forbidden: 403
if (int(user_type) == int(ParticipantType.AGENCY)) & (any_type_is_agency):
# self-assignment: agency sets agency participant
raise werkzeug.exceptions.Forbidden(f"An agency cannot self-register for a shipcall. The request is issued by an agency-user and tries to assign an AGENCY as the participant of the shipcall.") # Forbidden: 403
if len(agency_entries)>0:
# agency participant exists: participant id must be the same as shipcall participant map entry
matching_spm_entry = [spm_entry for spm_entry in spm_shipcall_data if (spm_entry.get("participant_id")==user_data["id"]) & (int(spm_entry.get("type"))==int(ParticipantType.AGENCY))]
if len(matching_spm_entry)==0:
# An AGENCY was found, but a different participant_id is assigned to that AGENCY
raise werkzeug.exceptions.Forbidden(f"A different participant_id is assigned as the AGENCY of this shipcall. Provided ID: {user_data.get('id')}, Assigned ShipcallParticipantMap: {agency_entries}") # Forbidden: 403
else:
# a matching agency was found: no violation
return
else:
# agency participant does not exist: there is no assigned agency role for the shipcall {shipcall_id}
raise werkzeug.exceptions.Forbidden(f"There is no assigned agency for this shipcall. Shipcall ID: {loadedModel['id']}") # Forbidden: 403
return return
@staticmethod @staticmethod

View File

@ -43,7 +43,7 @@ def get_ship_id_dictionary():
def get_shipcall_id_dictionary(): def get_shipcall_id_dictionary():
# get all ships # get all ships
response,status_code,header = GetShipcalls(token=None) response,status_code,header = GetShipcalls(options={'past_days':30000})
# build a dictionary of id:item pairs, so one can select the respective participant # build a dictionary of id:item pairs, so one can select the respective participant
shipcalls = json.loads(response) shipcalls = json.loads(response)

View File

@ -5,16 +5,23 @@ import jwt
import json import json
import requests import requests
import datetime import datetime
import werkzeug
from marshmallow import ValidationError from marshmallow import ValidationError
from BreCal import local_db
from BreCal.schemas.model import Participant_Assignment, EvaluationType, ShipcallType from BreCal.schemas.model import Participant_Assignment, EvaluationType, ShipcallType
from BreCal.stubs.shipcall import create_postman_stub_shipcall, get_stub_valid_shipcall_arrival, get_stub_valid_shipcall_departure, get_stub_valid_shipcall_shifting, get_stub_shipcall_arrival_invalid_missing_eta, get_stub_shipcall_shifting_invalid_missing_eta, get_stub_shipcall_shifting_invalid_missing_etd, get_stub_shipcall_arrival_invalid_missing_type, get_stub_shipcall_departure_invalid_missing_etd from BreCal.stubs.shipcall import create_postman_stub_shipcall, get_stub_valid_shipcall_arrival, get_stub_valid_shipcall_departure, get_stub_valid_shipcall_shifting, get_stub_shipcall_arrival_invalid_missing_eta, get_stub_shipcall_shifting_invalid_missing_eta, get_stub_shipcall_shifting_invalid_missing_etd, get_stub_shipcall_arrival_invalid_missing_type, get_stub_shipcall_departure_invalid_missing_etd
from BreCal.stubs.participant import get_stub_list_of_valid_participants from BreCal.stubs.participant import get_stub_list_of_valid_participants
from BreCal.validators.input_validation import validation_error_default_asserts from BreCal.validators.input_validation import validation_error_default_asserts
from BreCal.schemas.model import ParticipantType from BreCal.schemas.model import ParticipantType
from BreCal.validators.input_validation_shipcall import InputValidationShipcall
@pytest.fixture instance_path = os.path.join(os.path.expanduser('~'), "brecal", "src", "server", "instance", "instance")
def get_stub_token(scope="session"): local_db.initPool(os.path.dirname(instance_path), connection_filename="connection_data_local.json")
@pytest.fixture(scope="session")
def get_stub_token():
""" """
performs a login to the user 'maxm' and returns the respective url and the token. The token will be used in performs a login to the user 'maxm' and returns the respective url and the token. The token will be used in
further requests in the following format (example of post-request): further requests in the following format (example of post-request):
@ -35,6 +42,19 @@ def get_stub_token(scope="session"):
token = user.get("token") token = user.get("token")
return locals() return locals()
@pytest.fixture(scope="session")
def get_shipcall_id_after_stub_post_request(get_stub_token):
url, token = get_stub_token["url"], get_stub_token["token"]
post_data = get_stub_valid_shipcall_arrival()
response = requests.post(
f"{url}/shipcalls", headers={"Content-Type":"text", "Authorization":f"Bearer {token}"}, json=post_data
)
assert response.status_code==201
shipcall_id = response.json().get("id")
assert shipcall_id is not None
return locals()
def test_shipcall_post_request_fails_when_ship_id_is_invalid(get_stub_token): def test_shipcall_post_request_fails_when_ship_id_is_invalid(get_stub_token):
url, token = get_stub_token["url"], get_stub_token["token"] url, token = get_stub_token["url"], get_stub_token["token"]
@ -584,3 +604,134 @@ def test_shipcall_post_type_is_wrong(get_stub_token):
) )
validation_error_default_asserts(response) validation_error_default_asserts(response)
return return
def test_shipcall_put_request_fails_when_different_participant_id_is_assigned(get_shipcall_id_after_stub_post_request):
url, token, shipcall_id = get_shipcall_id_after_stub_post_request["token"], get_shipcall_id_after_stub_post_request["url"], get_shipcall_id_after_stub_post_request["shipcall_id"]
post_data = get_stub_valid_shipcall_arrival()
post_data["id"] = shipcall_id
user_data = {'id':6, 'participant_id':1}
loadedModel = post_data
content = post_data
spm_shipcall_data = [{'participant_id': 6, 'type': 4},
{'participant_id': 3, 'type': 1},
{'participant_id': 4, 'type': 2},
{'participant_id': 5, 'type': 8}]
# agency with different participant id is assigned
ivs = InputValidationShipcall()
with pytest.raises(werkzeug.exceptions.Forbidden, match=f"A different participant_id is assigned as the AGENCY of this shipcall. "):
ivs.check_agency_in_shipcall_participant_map(user_data, loadedModel, content, spm_shipcall_data)
return
def test_shipcall_put_request_success(get_shipcall_id_after_stub_post_request):
url, token, shipcall_id = get_shipcall_id_after_stub_post_request["token"], get_shipcall_id_after_stub_post_request["url"], get_shipcall_id_after_stub_post_request["shipcall_id"]
post_data = get_stub_valid_shipcall_arrival()
post_data["id"] = shipcall_id
# success happens, when shipcall data is valid, the user is authorized and the assigned spm shipcall data is suitable
user_data = {'id':6, 'participant_id':1}
loadedModel = post_data
content = post_data
spm_shipcall_data = [{'participant_id': 6, 'type': 8},
{'participant_id': 3, 'type': 1},
{'participant_id': 4, 'type': 2},
{'participant_id': 5, 'type': 4}]
# success
ivs = InputValidationShipcall()
ivs.check_agency_in_shipcall_participant_map(user_data, loadedModel, content, spm_shipcall_data)
return
def test_shipcall_put_request_fails_when_no_agency_is_assigned(get_shipcall_id_after_stub_post_request):
url, token, shipcall_id = get_shipcall_id_after_stub_post_request["token"], get_shipcall_id_after_stub_post_request["url"], get_shipcall_id_after_stub_post_request["shipcall_id"]
post_data = get_stub_valid_shipcall_arrival()
post_data["id"] = shipcall_id
user_data = {'id':6, 'participant_id':1}
loadedModel = post_data
content = post_data
spm_shipcall_data = [
{'participant_id': 3, 'type': 1},
{'participant_id': 4, 'type': 2},
{'participant_id': 5, 'type': 4}]
# no agency assigned
ivs = InputValidationShipcall()
with pytest.raises(werkzeug.exceptions.Forbidden, match=f"There is no assigned agency for this shipcall."):
ivs.check_agency_in_shipcall_participant_map(user_data, loadedModel, content, spm_shipcall_data)
return
def test_shipcall_put_request_fails_when_user_is_not_authorized(get_shipcall_id_after_stub_post_request):
url, token, shipcall_id = get_shipcall_id_after_stub_post_request["token"], get_shipcall_id_after_stub_post_request["url"], get_shipcall_id_after_stub_post_request["shipcall_id"]
post_data = get_stub_valid_shipcall_arrival()
post_data["id"] = shipcall_id
# user '1' is artificially set as participant 2, which has ParticipantType 4 (pilot), and is not authorized as an agency
user_data = {'id':1, 'participant_id':2}
loadedModel = post_data
content = post_data
spm_shipcall_data = [
{'participant_id': 2, 'type': 8},
{'participant_id': 3, 'type': 1},
{'participant_id': 4, 'type': 2},
{'participant_id': 5, 'type': 4}]
# current user is not authorized
ivs = InputValidationShipcall()
with pytest.raises(werkzeug.exceptions.Forbidden, match=f"PUT Requests for shipcalls can only be issued by AGENCY or BSMD users."):
ivs.check_agency_in_shipcall_participant_map(user_data, loadedModel, content, spm_shipcall_data)
return
def test_shipcall_put_request_fails_when_user_tries_self_assignment(get_shipcall_id_after_stub_post_request):
url, token, shipcall_id = get_shipcall_id_after_stub_post_request["token"], get_shipcall_id_after_stub_post_request["url"], get_shipcall_id_after_stub_post_request["shipcall_id"]
post_data = get_stub_valid_shipcall_arrival()
post_data["id"] = shipcall_id
user_data = {'id':1, 'participant_id':6}
loadedModel = post_data
content = post_data
spm_shipcall_data = [{'participant_id': 6, 'type': 8},
{'participant_id': 3, 'type': 1},
{'participant_id': 4, 'type': 2},
{'participant_id': 5, 'type': 4}]
# self-assignment. User is participant 6, and wants to assign participant 6.
ivs = InputValidationShipcall()
with pytest.raises(werkzeug.exceptions.Forbidden, match=f"An agency cannot self-register for a shipcall. The request is issued by an agency-user and tries to assign an AGENCY as the participant of the shipcall."):
ivs.check_agency_in_shipcall_participant_map(user_data, loadedModel, content, spm_shipcall_data)
return
def test_shipcall_put_request_fails_input_validation_shipcall_when_shipcall_is_canceled(get_stub_token):
url, token = get_stub_token["url"], get_stub_token["token"]
# get all shipcalls and grab shipcall with ID 4
# #TODO: there must be a better way to accomplish this easily...
response = requests.get(f"{url}/shipcalls", headers={"Content-Type":"text", "Authorization":f"Bearer {token}"}, params={"past_days":30000})
assert response.status_code==200
assert isinstance(response.json(), list)
shipcalls = response.json()
shipcall_id = 4
sh4 = [sh for sh in shipcalls if sh.get("id")==shipcall_id][0]
put_data = {k:v for k,v in sh4.items() if k in ["eta", "type", "ship_id", "arrival_berth_id", "participants"]}
put_data["id"] = shipcall_id
loadedModel = put_data
content = put_data
# a canceled shipcall cannot be selected
with pytest.raises(ValidationError, match="The shipcall with id 'shipcall_id' is canceled. A canceled shipcall may not be changed."):
InputValidationShipcall.check_shipcall_is_canceled(loadedModel, content)
return