diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 5bff8861f..cee92cf68 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -70,6 +70,11 @@ v3.0-19 | 2024-08-09 - ``soc-usage`` +v3.0-19 | 2024-05-24 +"""""""""""""""""""" +- Add authorization check on sensors referred to in flex-model and flex-context fields for `/sensors//schedules/trigger` (POST). + + v3.0-18 | 2024-03-07 """""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index f7e390dfb..40ab7c569 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -7,6 +7,11 @@ FlexMeasures Changelog v0.23.0 | August XX, 2024 ============================ +Bugfixes +----------- + +* Add authorization check on sensors referred to in ``flex-model`` and ``flex-context`` fields [see `PR #1071 `_] + New features ------------- * Added support for adding custom titles the graphs on the asset page. This works via an extension to the sensors_to_show format. [see `PR #1125 `_] diff --git a/flexmeasures/api/common/responses.py b/flexmeasures/api/common/responses.py index c0e959a62..1e1df3aad 100644 --- a/flexmeasures/api/common/responses.py +++ b/flexmeasures/api/common/responses.py @@ -177,6 +177,7 @@ def invalid_role(requested_access_role: str) -> ResponseTuple: def invalid_sender( required_permissions: list[str] | None = None, + field_name: str | None = None, ) -> ResponseTuple: """ Signify that the sender is invalid to perform the request. Fits well with 403 errors. @@ -186,7 +187,11 @@ def invalid_sender( if required_permissions: message += f" It requires {p.join(required_permissions)} permission(s)." return ( - dict(result="Rejected", status="INVALID_SENDER", message=message), + dict( + result="Rejected", + status="INVALID_SENDER", + message={"json": {field_name: message}} if field_name else message, + ), FORBIDDEN_STATUS_CODE, ) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 252e3a6a5..d506e0d0b 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -18,6 +18,7 @@ unrecognized_event, unknown_schedule, invalid_flex_config, + invalid_sender, fallback_schedule_redirect, ) from flexmeasures.api.common.utils.validators import ( @@ -34,6 +35,10 @@ from flexmeasures.data.models.audit_log import AssetAuditLog from flexmeasures.data.models.user import Account from flexmeasures.data.models.generic_assets import GenericAsset +from flexmeasures.data.models.planning.utils import ( + flex_context_loader, + flex_model_loader, +) from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.queries.utils import simplify_index from flexmeasures.data.schemas.sensors import SensorSchema, SensorIdField @@ -227,6 +232,26 @@ def get_data(self, sensor_data_description: dict): location="json", ) @permission_required_for_context("create-children", ctx_arg_name="sensor") + @permission_required_for_context( + "read", + ctx_arg_name="flex_model", + ctx_loader=flex_model_loader, + pass_ctx_to_loader=True, + error_handler=lambda context, permission, origin: invalid_sender( + required_permissions=[f"{permission} sensor {context.id}"], + field_name=origin, + ), + ) + @permission_required_for_context( + "read", + ctx_arg_name="flex_context", + ctx_loader=flex_context_loader, + pass_ctx_to_loader=True, + error_handler=lambda context, permission, origin: invalid_sender( + required_permissions=[f"{permission} sensor {context.id}"], + field_name=origin, + ), + ) def trigger_schedule( self, sensor: Sensor, diff --git a/flexmeasures/api/v3_0/tests/conftest.py b/flexmeasures/api/v3_0/tests/conftest.py index a8c534e1b..d1de198da 100644 --- a/flexmeasures/api/v3_0/tests/conftest.py +++ b/flexmeasures/api/v3_0/tests/conftest.py @@ -220,3 +220,16 @@ def add_temperature_measurements(db, source: Source, sensor: Sensor): for event_start, event_value in zip(event_starts, event_values) ] db.session.add_all(beliefs) + + +@pytest.fixture(scope="module") +def setup_capacity_sensor_on_asset_in_supplier_account(db, setup_generic_assets): + asset = setup_generic_assets["test_wind_turbine"] + sensor = Sensor( + name="capacity", + generic_asset=asset, + event_resolution=timedelta(minutes=15), + unit="MVA", + ) + db.session.add(sensor) + return sensor diff --git a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py index 1ccb94210..1bb90dfc9 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py @@ -84,7 +84,7 @@ def test_trigger_schedule_with_invalid_flexmodel( ) print("Server responded with:\n%s" % trigger_schedule_response.json) check_deprecation(trigger_schedule_response, deprecation=None, sunset=None) - assert trigger_schedule_response.status_code == 422 + assert trigger_schedule_response.status_code == 422 # Unprocessable entity assert field in trigger_schedule_response.json["message"]["json"] if isinstance(trigger_schedule_response.json["message"]["json"], str): # ValueError @@ -422,3 +422,79 @@ def test_get_schedule_fallback_not_redirect( assert schedule["scheduler_info"]["scheduler"] == "StorageFallbackScheduler" app.config["FLEXMEASURES_FALLBACK_REDIRECT"] = False + + +@pytest.mark.parametrize( + "message, flex_config, field, err_msg", + [ + ( + message_for_trigger_schedule(), + "flex-context", + "site-consumption-capacity", + "requires read sensor", + ), + ( + message_for_trigger_schedule(), + "flex-model", + "site-consumption-capacity", + "requires read sensor", + ), + ], +) +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +def test_trigger_schedule_with_unauthorized_sensor( + app, + add_battery_assets, + setup_capacity_sensor_on_asset_in_supplier_account, + keep_scheduling_queue_empty, + message, + flex_config, + field, + err_msg, + requesting_user, +): + """Test triggering a schedule using a flex config that refers to a capacity sensor from a different account. + + The user is not authorized to read sensors from the other account, + so we expect a 403 (Forbidden) response referring to the relevant flex-config field. + """ + sensor = add_battery_assets["Test battery"].sensors[0] + with app.test_client() as client: + if flex_config not in message: + message[flex_config] = {} + sensor_id = setup_capacity_sensor_on_asset_in_supplier_account.id + message[flex_config][field] = {"sensor": sensor_id} + + trigger_schedule_response = client.post( + url_for("SensorAPI:trigger_schedule", id=sensor.id), + json=message, + ) + print("Server responded with:\n%s" % trigger_schedule_response.json) + assert trigger_schedule_response.status_code == 403 # Forbidden + assert ( + f"{flex_config}.{field}.sensor" + in trigger_schedule_response.json["message"]["json"] + ) + if isinstance( + trigger_schedule_response.json["message"]["json"][ + f"{flex_config}.{field}.sensor" + ], + str, + ): + # ValueError + assert ( + err_msg + in trigger_schedule_response.json["message"]["json"][ + f"{flex_config}.{field}.sensor" + ] + ) + else: + # ValidationError (marshmallow) + assert ( + err_msg + in trigger_schedule_response.json["message"]["json"][ + f"{flex_config}.{field}.sensor" + ][field][0] + ) diff --git a/flexmeasures/auth/decorators.py b/flexmeasures/auth/decorators.py index 2655bb42a..ae42128cd 100644 --- a/flexmeasures/auth/decorators.py +++ b/flexmeasures/auth/decorators.py @@ -111,6 +111,7 @@ def permission_required_for_context( ctx_arg_name: str | None = None, ctx_loader: Callable | None = None, pass_ctx_to_loader: bool = False, + error_handler: Callable | None = None, ): """ This decorator can be used to make sure that the current user has the necessary permission to access the context. @@ -119,6 +120,7 @@ def permission_required_for_context( A 403 response is raised if there is no principal for the required permission. A 401 response is raised if the user is not authenticated at all. + A custom response can be generated by passing an error_handler, which should be a function that accepts the context, permission and a context origin. We will now explain how to load a context, and give an example: @@ -145,7 +147,9 @@ def view(resource_id: int, the_resource: Resource): The ctx_loader: - The ctx_loader can be a function without arguments or it takes the context loaded from the arguments as input (using pass_ctx_to_loader=True). + The ctx_loader can be a function without arguments, or it takes the context loaded from the arguments as input (using pass_ctx_to_loader=True). + It should return the context, a list of contexts, or a list of (context, origin) tuples, + where an origin defines where (e.g. what API field) the context came from, to help with responding with more informative errors. A special case is useful when the arguments contain the context ID (not the instance). Then, the loader can be a subclass of AuthModelMixin, and this decorator will look up the instance. @@ -168,38 +172,68 @@ def post(self, resource_data: dict): def wrapper(fn): @wraps(fn) def decorated_view(*args, **kwargs): - # load & check context - context: AuthModelMixin = None - - # first set context_from_args, if possible - context_from_args: AuthModelMixin = None - if ctx_arg_pos is not None and ctx_arg_name is not None: - context_from_args = args[ctx_arg_pos][ctx_arg_name] - elif ctx_arg_pos is not None: - context_from_args = args[ctx_arg_pos] - elif ctx_arg_name is not None: - context_from_args = kwargs[ctx_arg_name] - elif len(args) > 0: - context_from_args = args[0] - - # if a loader is given, use that, otherwise fall back to context_from_args - if ctx_loader is not None: - if pass_ctx_to_loader: - if inspect.isclass(ctx_loader) and issubclass( - ctx_loader, AuthModelMixin - ): - context = db.session.get(ctx_loader, context_from_args) - else: - context = ctx_loader(context_from_args) - else: - context = ctx_loader() - else: - context = context_from_args + context = load_context( + ctx_arg_pos, ctx_arg_name, ctx_loader, pass_ctx_to_loader, args, kwargs + ) - check_access(context, permission) + # skip check in case (optional) argument was not passed + if context is None: + return fn(*args, **kwargs) + + # Check access for possibly multiple contexts + if not isinstance(context, list): + context = [context] + for ctx in context: + if isinstance(ctx, tuple): + c = ctx[0] # c[0] is the context, c[1] is its origin + # the context loader may narrow down the origin of the context (e.g. a nested field rather than a function argument) + origin = ctx[1] + else: + c = ctx + origin = ctx_arg_name + try: + check_access(c, permission) + except Exception as e: # noqa: B902 + if error_handler: + return error_handler(c, permission, origin) + raise e return fn(*args, **kwargs) return decorated_view return wrapper + + +def load_context( + ctx_arg_pos, ctx_arg_name, ctx_loader, pass_ctx_to_loader, args, kwargs +) -> AuthModelMixin | None: + + # first set context_from_args, if possible + context_from_args: AuthModelMixin | None = None + if ctx_arg_pos is not None and ctx_arg_name is not None: + context_from_args = args[ctx_arg_pos][ctx_arg_name] + elif ctx_arg_pos is not None: + context_from_args = args[ctx_arg_pos] + elif ctx_arg_name is not None: + context_from_args = kwargs.get(ctx_arg_name) + # skip check in case (optional) argument was not passed + if context_from_args is None: + return None + elif len(args) > 0: + context_from_args = args[0] + + # if a loader is given, use that, otherwise fall back to context_from_args + if ctx_loader is not None: + if pass_ctx_to_loader: + if inspect.isclass(ctx_loader) and issubclass(ctx_loader, AuthModelMixin): + context = db.session.get(ctx_loader, context_from_args) + else: + context = ctx_loader(context_from_args) + else: + context = ctx_loader() + else: + context = context_from_args + if context is None: + raise LookupError(f"No context could be loaded from {context_from_args}.") + return context diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 5cb8758e6..028890db2 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +from functools import partial from packaging import version from datetime import date, datetime, timedelta @@ -451,3 +452,82 @@ def nanmin_of_series_and_value(s: pd.Series, value: float | pd.Series) -> pd.Ser # [right]: datetime64[ns, UTC] value = value.tz_convert("UTC") return s.fillna(value).clip(upper=value) + + +def sensor_loader(data, parent_key: str) -> list[tuple[Sensor, str]]: + """Load all sensors referenced by their ID in a nested dict or list, along with the fields referring to them. + + :param data: nested dict or list + :param parent_key: 'flex-model' or 'flex-context' + :returns: list of sensor-field tuples + """ + sensor_ids = find_sensor_ids(data, parent_key) + sensors = [ + (db.session.get(Sensor, sensor_id), field_name) + for sensor_id, field_name in sensor_ids + ] + return sensors + + +flex_model_loader = partial(sensor_loader, parent_key="flex-model") +flex_context_loader = partial(sensor_loader, parent_key="flex-context") + + +def find_sensor_ids(data, parent_key="") -> list[tuple[int, str]]: + """ + Recursively find all sensor IDs in a nested dictionary or list along with the fields referring to them. + + Args: + data (dict or list): The input data which can be a dictionary or a list containing nested dictionaries and lists. + parent_key (str): The key of the parent element in the recursion, used to track the referring fields. + + Returns: + list: A list of tuples, each containing a sensor ID and the referring field. + + Example: + nested_dict = { + "flex-model": [ + { + "sensor": 931, + "soc-at-start": 12.1, + "soc-unit": "kWh", + "soc-targets": [ + { + "value": 25, + "datetime": "2015-06-02T16:00:00+00:00" + }, + ], + "soc-minima": {"sensor": 300}, + "soc-min": 10, + "soc-max": 25, + "charging-efficiency": "120%", + "discharging-efficiency": {"sensor": 98}, + "storage-efficiency": 0.9999, + "power-capacity": "25kW", + "consumption-capacity": {"sensor": 42}, + "production-capacity": "30 kW" + }, + ], + } + + sensor_ids = find_sensor_ids(nested_dict) + print(sensor_ids) # Output: [(931, 'sensor'), (300, 'soc-minima.sensor'), (98, 'discharging-efficiency.sensor'), (42, 'consumption-capacity.sensor')] + """ + sensor_ids = [] + + if isinstance(data, dict): + for key, value in data.items(): + new_parent_key = f"{parent_key}.{key}" if parent_key else key + if key[-6:] == "sensor": + sensor_ids.append((value, new_parent_key)) + elif key[-7:] == "sensors": + for v in value: + sensor_ids.append((v, new_parent_key)) + else: + sensor_ids.extend(find_sensor_ids(value, new_parent_key)) + elif isinstance(data, list): + for index, item in enumerate(data): + new_parent_key = f"{parent_key}[{index}]" + sensor_ids.extend(find_sensor_ids(item, new_parent_key)) + + return sensor_ids diff --git a/flexmeasures/data/schemas/tests/test_times.py b/flexmeasures/data/schemas/tests/test_times.py index 8f3415a52..f113aae3a 100644 --- a/flexmeasures/data/schemas/tests/test_times.py +++ b/flexmeasures/data/schemas/tests/test_times.py @@ -65,7 +65,7 @@ def test_duration_field_nominal_grounded( ("1H", "Unable to parse duration string"), ("PP1M", "time designator 'T' missing"), ("PT2D", "Unrecognised ISO 8601 date format"), - ("PT40S", "FlexMeasures only support multiples of 1 minute."), + ("PT40S", "FlexMeasures only supports multiples of 1 minute."), ], ) def test_duration_field_invalid(duration_input, error_msg): diff --git a/flexmeasures/data/schemas/times.py b/flexmeasures/data/schemas/times.py index 625099b53..9367e7270 100644 --- a/flexmeasures/data/schemas/times.py +++ b/flexmeasures/data/schemas/times.py @@ -36,7 +36,7 @@ def _deserialize(self, value, attr, obj, **kwargs) -> timedelta | isodate.Durati ) if duration_value.seconds % 60 != 0 or duration_value.microseconds != 0: raise DurationValidationError( - "FlexMeasures only support multiples of 1 minute." + "FlexMeasures only supports multiples of 1 minute." ) return duration_value