diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 765607b504..f0cef3aa57 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -143,6 +143,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 ab1aa94f1a..b5efb38dcd 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -48,6 +48,7 @@ Bugfixes * Resolved a crash in the "latest jobs" table when sorting was applied, and corrected a typo in the Jobs API Swagger UI documentation. [see `PR #1821 `_] * Enhance dates on UI(sensor page) to be human friendly when representing future dates [see `PR #1832 `_] * Fix spinner not disappearing on the asset graph page [see `PR #1831 `_] +* Add authorization check on sensors referred to in ``flex-model`` and ``flex-context`` fields [see `PR #1071 `_] v0.29.1 | November 5, 2025 ============================ diff --git a/flexmeasures/api/common/responses.py b/flexmeasures/api/common/responses.py index 11eb482971..28a39f8712 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/assets.py b/flexmeasures/api/v3_0/assets.py index 88e93b0300..a7a32710c6 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -53,6 +53,7 @@ from flexmeasures.api.common.utils.api_utils import get_accessible_accounts from flexmeasures.api.common.responses import ( invalid_flex_config, + invalid_sender, request_processed, ) from flexmeasures.api.common.schemas.users import AccountIdField @@ -64,6 +65,10 @@ from flexmeasures.data.schemas.sensors import ( SensorSchema, ) +from flexmeasures.data.models.planning.utils import ( + flex_context_loader, + flex_model_loader, +) from flexmeasures.data.models.time_series import Sensor from flexmeasures.utils.time_utils import naturalized_datetime_str from flexmeasures.data.utils import get_downsample_function_and_value @@ -1189,6 +1194,26 @@ def update_keep_legends_below_graphs(self, **kwargs): # Simplification of checking for create-children access on each of the flexible sensors, # which assumes each of the flexible sensors belongs to the given asset. @permission_required_for_context("create-children", ctx_arg_name="asset") + @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, asset: GenericAsset, diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 15ade3ec27..b9c3f5278a 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -24,6 +24,7 @@ unrecognized_event, unknown_schedule, invalid_flex_config, + invalid_sender, fallback_schedule_redirect, ) from flexmeasures.api.common.utils.validators import ( @@ -43,6 +44,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 ( # noqa F401 @@ -114,13 +119,14 @@ class TriggerScheduleKwargsSchema(Schema): metadata=dict( description="The flex-model is validated according to the scheduler's `FlexModelSchema`.", ), + load_default={}, ) flex_context = fields.Dict( - required=False, data_key="flex-context", metadata=dict( description="The flex-context is validated according to the scheduler's `FlexContextSchema`.", ), + load_default={}, ) force_new_job_creation = fields.Boolean( required=False, @@ -594,6 +600,26 @@ def get_data(self, id: int, **sensor_data_description: dict): ) @use_kwargs(TriggerScheduleKwargsSchema, 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 ca84257116..89cfc94878 100644 --- a/flexmeasures/api/v3_0/tests/conftest.py +++ b/flexmeasures/api/v3_0/tests/conftest.py @@ -234,3 +234,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 5710ee493e..07169599fa 100644 --- a/flexmeasures/api/v3_0/tests/test_sensor_schedules.py +++ b/flexmeasures/api/v3_0/tests/test_sensor_schedules.py @@ -85,7 +85,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 @@ -444,3 +444,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 76e51b23d0..85a5fb6900 100644 --- a/flexmeasures/auth/decorators.py +++ b/flexmeasures/auth/decorators.py @@ -110,6 +110,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. @@ -118,6 +119,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: @@ -144,7 +146,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. @@ -167,38 +171,64 @@ 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) + # 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 953d4ce789..a3ac8cfcb2 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 @@ -13,6 +14,7 @@ from flexmeasures.data.models.time_series import Sensor, TimedBelief from flexmeasures.data.models.planning import StockCommitment from flexmeasures.data.queries.utils import simplify_index +from flexmeasures.data.schemas.sensors import SensorIdField from flexmeasures.utils.flexmeasures_inflection import capitalize, pluralize from flexmeasures.utils.unit_utils import ur, convert_units @@ -554,3 +556,81 @@ def initialize_device_commitment( stock_commitment["device"] = device stock_commitment["class"] = StockCommitment return stock_commitment + + +def sensor_loader( + data: dict | list[dict], parent_key: str = "" +) -> list[tuple[Sensor, str]]: + """Recursively load all sensors referenced by ID in a nested dict or list, along with the fields referring to them. + + :param data: Nested dict or list thereof. + :param parent_key: The key of the parent element in the recursion, used to track the referring fields. + For example, 'flex-model' or 'flex-context'. + :returns: A list of tuples, each containing a sensor and the field that referred to it. + + 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" + }, + ], + } + + sensors = sensor_loader(nested_dict) + print(sensors) # Output: [(, 'sensor'), (, 'soc-minima.sensor'), (, 'discharging-efficiency.sensor'), (, '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 = deserialize_to_sensor_if_needed(value) + sensor_ids.append((sensor, new_parent_key)) + elif key[-7:] == "sensors": + for v in value: + sensor = deserialize_to_sensor_if_needed(v) + sensor_ids.append((sensor, new_parent_key)) + else: + sensor_ids.extend(sensor_loader(value, new_parent_key)) + elif isinstance(data, list): + for index, item in enumerate(data): + new_parent_key = f"{parent_key}[{index}]" + sensor_ids.extend(sensor_loader(item, new_parent_key)) + + return sensor_ids + + +def deserialize_to_sensor_if_needed(value: int | Sensor) -> Sensor: + """Ensure all sensor values are deserialized. + + The multi-asset flex-model already deserialized the power sensors of each device. + Other fields still need to be deserialized. + """ + if isinstance(value, Sensor): + sensor = value + else: + sensor = SensorIdField().deserialize(value) + return sensor + + +flex_model_loader = partial(sensor_loader, parent_key="flex-model") +flex_context_loader = partial(sensor_loader, parent_key="flex-context") diff --git a/flexmeasures/data/schemas/tests/test_times.py b/flexmeasures/data/schemas/tests/test_times.py index 8f3415a525..f113aae3a1 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 dd75567f4d..891284e680 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 diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index e1ce0f9019..97c41995da 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4339,11 +4339,13 @@ }, "flex-model": { "type": "object", + "default": {}, "description": "The flex-model is validated according to the scheduler's `FlexModelSchema`.", "additionalProperties": {} }, "flex-context": { "type": "object", + "default": {}, "description": "The flex-context is validated according to the scheduler's `FlexContextSchema`.", "additionalProperties": {} },