diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4074c3e9f4..99f4770f2f 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -40,6 +40,7 @@ New features ------------- * Added API and UI support for copying assets and their subtrees [see `PR #2017 `_ and `PR #2120 `_] * Improve UX after deleting a child asset through the UI [see `PR #2119 `_] +* Allow admins and consultants to create accounts via a new ``POST /api/v3_0/accounts`` endpoint and a corresponding UI form [see `PR #2176 `_] * Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 `_ and `PR #2151 `_] * Support sensor references for efficiency fields in storage flex-models [see `PR #2142 `_] * Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 `_ and `PR #2213 `_] diff --git a/documentation/concepts/security_auth.rst b/documentation/concepts/security_auth.rst index c7f80e95be..0364ffed72 100644 --- a/documentation/concepts/security_auth.rst +++ b/documentation/concepts/security_auth.rst @@ -60,12 +60,12 @@ User and Account Roles We already discussed certain conditions under which a user has access to data ― being a certain user or belonging to a specific account. Furthermore, authorization conditions can also be implemented via *roles*: -* ``Account roles`` are often used for authorization. We support several roles which are mentioned in the USEF framework but more roles are possible (e.g. defined by custom-made services, see below). For example, a user might be authorized to write sensor data if they belong to an account with the "MDC" account role ("MDC" being short for meter data company). +* ``Account roles`` are often used for authorization. They are extensible: hosts and custom services can define their own roles. In the core FlexMeasures codebase, the ``Consultancy`` account role currently has built-in authorization behavior: together with the user role ``consultant``, it allows consultancy accounts to create client accounts and access consultancy-related data. * ``User roles`` give a user personal authorizations. For instance, we have a few `admin`\ s who can perform all actions, and `admin-reader`\ s who can read everything. Other roles have only an effect within the user's account, e.g. there could be an "HR" role which allows to edit user data like surnames within the account. We look into supported user roles in more detail below. -Roles cannot be edited via the UI at the moment. They are decided when a user or account is created in the CLI (for adding roles later, we use the database for now). Editing roles in UI and CLI is future work. +Roles are not a closed built-in list. Some are hardcoded in the core authorization model, while others are installation-specific. Both Account and User's roles can be managed through the account UI and API. .. note:: Custom energy flexibility services which are developed on top of FlexMeasures can also add their own kind of authorization, at least for the endpoints they define - using roles. @@ -100,9 +100,9 @@ These roles are natively supported and give users more rights: Consultancy ^^^^^^^^^^^ -A special case of authorization is consultancy - a consultancy account can read data from other accounts (usually their clients ― this is handy for servicing them). -For this, accounts have an attribute called ``consultancy_account_id``. Users in the consultancy account with the role `consultant` can read data in their client accounts. -We plan to introduce some editing/creation capabilities in the future. +A special case of authorization is consultancy: a consultancy account can read data from other accounts (usually their clients, which is handy for servicing them). +For this, accounts have an attribute called ``consultancy_account_id``. Users in the consultancy account with the user role ``consultant`` can read data in their client accounts. -Setting an account as the consultancy account is something only admins can do. -It is possible via the ``/accounts`` PATCH endpoint, but also in the UI. You can also specify a consultancy account when creating a client account, which for now happens only in the CLI: ``flexmeasures add account --name "Account2" --consultancy 1`` makes account 1 the consultancy account for account 2. +In addition, consultants can create/edit client accounts through the API and UI, when their own account has the Consultancy account role. When they create a client account, it is automatically linked to the consultancy account as client account. + +Setting or changing ``consultancy_account_id`` arbitrarily remains an admin capability. Admins can do this via the ``/accounts`` PATCH endpoint and in the UI. diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py index 062a59a98f..689fd90ede 100644 --- a/flexmeasures/api/v3_0/__init__.py +++ b/flexmeasures/api/v3_0/__init__.py @@ -41,7 +41,11 @@ from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema from flexmeasures.data.schemas.sensors import QuantitySchema, TimeSeriesSchema -from flexmeasures.data.schemas.account import AccountSchema +from flexmeasures.data.schemas.account import ( + AccountSchema, + AccountCreateSchema, + AccountPatchSchema, +) from flexmeasures.api.v3_0.accounts import AccountAPIQuerySchema from flexmeasures.api.v3_0.users import UserAPIQuerySchema, AuthRequestSchema from flexmeasures.utils.doc_utils import rst_to_openapi @@ -160,6 +164,8 @@ def create_openapi_specs(app: Flask): ("CopyAssetSchema", CopyAssetSchema), ("DefaultAssetViewJSONSchema", DefaultAssetViewJSONSchema), ("AccountSchema", AccountSchema(partial=True)), + ("AccountCreateSchema", AccountCreateSchema()), + ("AccountPatchSchema", AccountPatchSchema()), ("AccountAPIQuerySchema", AccountAPIQuerySchema), ("AuthRequestSchema", AuthRequestSchema), ] diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 2bed3cf33f..04388d639f 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -8,7 +8,10 @@ from sqlalchemy import or_, select, func from flask_sqlalchemy.pagination import SelectPagination -from flexmeasures.auth.policy import user_has_admin_access +from flexmeasures.auth.policy import ( + user_has_admin_access, + FlexMeasuresPlatform, +) from flexmeasures.auth.decorators import permission_required_for_context from flexmeasures.data.models.annotations import Annotation, get_or_create_annotation from flexmeasures.data.models.audit_log import AuditLog @@ -16,7 +19,11 @@ from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.services.accounts import get_accounts, get_audit_log_records from flexmeasures.api.common.schemas.users import AccountIdField -from flexmeasures.data.schemas.account import AccountSchema +from flexmeasures.data.schemas.account import ( + AccountSchema, + AccountCreateSchema, + AccountPatchSchema, +) from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.utils.time_utils import server_now from flexmeasures.api.common.schemas.users import AccountAPIQuerySchema @@ -24,14 +31,14 @@ """ API endpoints to manage accounts. -Both POST (to create) and DELETE are not accessible via the API, but as CLI functions. -Editing (PATCH) is also not yet implemented, but might be next, e.g. for the name or roles. +DELETE is not accessible via the API, but as a CLI function. """ # Instantiate schemas outside of endpoint logic to minimize response time account_schema = AccountSchema() accounts_schema = AccountSchema(many=True) -partial_account_schema = AccountSchema(partial=True) +partial_account_schema = AccountPatchSchema() +account_create_schema = AccountCreateSchema() annotation_schema = AnnotationSchema() @@ -178,6 +185,58 @@ def index( return response, 200 + @route("", methods=["POST"]) + @use_args(account_create_schema, arg_name="account_data") + @permission_required_for_context( + "create-children", + ctx_loader=FlexMeasuresPlatform.init, + ) + @as_json + def post(self, account_data: dict): + """ + .. :quickref: Accounts; Create an account. + --- + post: + summary: Create a new account. + description: | + Create a new account with a required name. + + - Admin users can create accounts. + - Consultant users can create accounts only if their account has the + `CONSULTANCY_ACCOUNT_ROLE` account role. + - For consultant users, the newly created account is automatically + linked to their own account as consultancy account. + + security: + - ApiKeyAuth: [] + requestBody: + description: Account fields for creation. + required: true + content: + application/json: + schema: AccountCreateSchema + example: + name: New Customer Account + consultancy_account_id: 2 + responses: + 201: + description: PROCESSED + 401: + description: UNAUTHORIZED + 403: + description: INVALID_SENDER + 422: + description: UNPROCESSABLE_ENTITY + tags: + - Accounts + """ + + account = Account(**account_data) + db.session.add(account) + db.session.commit() + + return account_schema.dump(account), 201 + @route("/", methods=["GET"]) @use_kwargs({"account": AccountIdField(data_key="id")}, location="path") @permission_required_for_context("read", ctx_arg_name="account") @@ -259,9 +318,10 @@ def patch(self, account_data: dict, id: int, account: Account): required: true content: application/json: - schema: AccountSchema + schema: AccountPatchSchema example: name: Test Account Updated + account_roles: [1, 3] primary_color: '#1a3443' secondary_color: '#f1a122' logo_url: 'https://example.com/logo.png' @@ -326,7 +386,9 @@ def patch(self, account_data: dict, id: int, account: Account): "logo_url", "consultancy_account_id", "attributes", + "account_roles", ] + modified_fields = { field: getattr(account, field) for field in fields_to_check diff --git a/flexmeasures/api/v3_0/tests/test_accounts_api.py b/flexmeasures/api/v3_0/tests/test_accounts_api.py index 5906f2c2b4..d03b47eadb 100644 --- a/flexmeasures/api/v3_0/tests/test_accounts_api.py +++ b/flexmeasures/api/v3_0/tests/test_accounts_api.py @@ -4,7 +4,10 @@ from flask import url_for import pytest +from sqlalchemy import select +from flexmeasures.data.models.user import Account, AccountRole +from flexmeasures.auth.policy import CONSULTANCY_ACCOUNT_ROLE from flexmeasures.data.services.users import find_user_by_email @@ -178,7 +181,7 @@ def test_get_one_user_audit_log_consultant( @pytest.mark.parametrize( "requesting_user, expected_status_code", [ - ("test_consultant@seita.nl", 401), + ("test_consultant@seita.nl", 403), ], indirect=["requesting_user"], ) @@ -189,6 +192,7 @@ def test_consultant_cannot_update_account_consultant( requesting_user, expected_status_code, ): + """Test that consultant cannot change consultancy_account_id to a different account.""" client_accounts = requesting_user.account.consultancy_client_accounts test_user_account_id = client_accounts[0].id if client_accounts else None @@ -283,3 +287,174 @@ def test_patch_account_attributes_with_consultancy( response.json["consultancy_account_id"] == consultancy_client_account.consultancy_account_id ) + + +@pytest.mark.parametrize( + "requesting_user, status_code", + [ + (None, 401), + ("test_prosumer_user@seita.nl", 403), + ("test_admin_user@seita.nl", 201), + ("test_consultant@seita.nl", 201), + ("test_consultancy_user_without_consultant_access@seita.nl", 403), + ], + indirect=["requesting_user"], +) +def test_post_account(client, setup_api_test_data, requesting_user, status_code, db): + payload = { + "name": f"Created Account {requesting_user.id if requesting_user else 'anon'}", + } + + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == status_code + + if status_code == 201: + created = db.session.execute( + select(Account).filter_by(name=payload["name"]) + ).scalar_one_or_none() + assert created is not None + + if requesting_user.has_role("consultant"): + assert created.consultancy_account_id == requesting_user.account.id + else: + assert created.consultancy_account_id is None + + +@pytest.mark.parametrize( + "requesting_user", + ["test_consultant@seita.nl"], + indirect=["requesting_user"], +) +def test_post_account_consultant_without_required_account_role_forbidden( + client, setup_api_test_data, requesting_user, db +): + + role = db.session.execute( + select(AccountRole).filter_by(name=CONSULTANCY_ACCOUNT_ROLE) + ).scalar_one_or_none() + assert role is not None + + # Save original roles to restore later + original_roles = list(requesting_user.account.account_roles) + + # Remove Consultancy role temporarily + requesting_user.account.account_roles = [ + r + for r in requesting_user.account.account_roles + if r.name != CONSULTANCY_ACCOUNT_ROLE + ] + db.session.commit() + + payload = { + "name": "Consultant Forbidden Account", + } + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == 403 + + # Restore original roles to avoid polluting other tests + requesting_user.account.account_roles = original_roles + db.session.commit() + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_patch_account_roles(client, setup_api_test_data, requesting_user, db): + target_account = find_user_by_email("test_prosumer_user_2@seita.nl").account + prosumer_role = db.session.execute( + select(AccountRole).filter_by(name="Prosumer") + ).scalar_one() + supplier_role = db.session.execute( + select(AccountRole).filter_by(name="Supplier") + ).scalar_one() + + response = client.patch( + url_for("AccountAPI:patch", id=target_account.id), + json={"account_roles": [prosumer_role.id, supplier_role.id]}, + ) + + assert response.status_code == 200 + role_names = {role["name"] for role in response.json["account_roles"]} + assert role_names == {"Prosumer", "Supplier"} + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_patch_account_roles_invalid_role_id( + client, setup_api_test_data, requesting_user +): + target_account = find_user_by_email("test_prosumer_user_2@seita.nl").account + + response = client.patch( + url_for("AccountAPI:patch", id=target_account.id), + json={"account_roles": [999999]}, + ) + + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "requesting_user, consultancy_account_id_type, status_code, error_message", + [ + # Consultant explicitly setting their own account ID - success + ("test_consultant@seita.nl", "own", 201, None), + # Consultant trying to set another account ID - forbidden (authorization error) + ( + "test_consultant@seita.nl", + "other", + 403, + "You can only set consultancy_account_id to your own account", + ), + # Consultant setting non-existent account ID - validation error + ("test_consultant@seita.nl", 999999, 422, "No account found with id 999999"), + # Admin setting any account ID - success + ("test_admin_user@seita.nl", "other", 201, None), + # User without consultant role - forbidden + ("test_consultancy_user_without_consultant_access@seita.nl", None, 403, None), + ], + indirect=["requesting_user"], +) +def test_post_account_with_consultancy_account_id( + client, + setup_api_test_data, + requesting_user, + consultancy_account_id_type, + status_code, + error_message, + db, +): + """Test creating accounts with various consultancy_account_id scenarios.""" + # Build payload with appropriate consultancy_account_id + payload = { + "name": f"Test Account {requesting_user.id if requesting_user else 'anon'}" + } + + if consultancy_account_id_type == "own": + payload["consultancy_account_id"] = requesting_user.account.id + elif consultancy_account_id_type == "other": + other_account = db.session.execute( + select(Account).filter_by(name="Test Prosumer Account") + ).scalar_one() + payload["consultancy_account_id"] = other_account.id + elif isinstance(consultancy_account_id_type, int): + payload["consultancy_account_id"] = consultancy_account_id_type + # else: None, don't add to payload + + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == status_code + + # For 422 validation errors, check the specific error message + # For 403 authorization errors, Flask uses a generic message + if error_message and status_code == 422: + assert error_message in str(response.json) + + if status_code == 201: + created = db.session.execute( + select(Account).filter_by(name=payload["name"]) + ).scalar_one_or_none() + assert created is not None + + if consultancy_account_id_type == "own": + assert created.consultancy_account_id == requesting_user.account.id + elif consultancy_account_id_type == "other": + other_account = db.session.execute( + select(Account).filter_by(name="Test Prosumer Account") + ).scalar_one() + assert created.consultancy_account_id == other_account.id diff --git a/flexmeasures/auth/policy.py b/flexmeasures/auth/policy.py index 1fa9f21417..9840bf2221 100644 --- a/flexmeasures/auth/policy.py +++ b/flexmeasures/auth/policy.py @@ -8,14 +8,17 @@ from flask_security import current_user from werkzeug.exceptions import Unauthorized, Forbidden - PERMISSIONS = ["create-children", "read", "update", "delete"] +# User Roles ADMIN_ROLE = "admin" ADMIN_READER_ROLE = "admin-reader" ACCOUNT_ADMIN_ROLE = "account-admin" CONSULTANT_ROLE = "consultant" +# Account Roels +CONSULTANCY_ACCOUNT_ROLE = "Consultancy" + # constants to allow access to certain groups EVERY_LOGGED_IN_USER = "every-logged-in-user" PRINCIPALS_TYPE = str | tuple[str] | list[str | tuple[str] | None] | None @@ -82,6 +85,25 @@ def __acl__(self) -> dict[str, PRINCIPALS_TYPE]: return {} +class FlexMeasuresPlatform(AuthModelMixin): + """Virtual platform resource to authorize top-level creations.""" + + @classmethod + def init(cls, context: dict | None = None) -> "FlexMeasuresPlatform": + return cls() + + def __acl__(self): + return { + "create-children": [ # this applies to accounts + f"role:{ADMIN_ROLE}", + ( # FM makes sure they are clients + f"role:{CONSULTANT_ROLE}", + f"account-role:{CONSULTANCY_ACCOUNT_ROLE}", + ), + ] + } + + def check_access(context: AuthModelMixin, permission: str): """ Check if current user can access this auth context if this permission @@ -239,3 +261,18 @@ def can_modify_role( return True return False + + +def user_can_add_accounts() -> bool: + """Check if the current user can create new accounts. + + Uses the ACL system to verify the user has permission to create + accounts on the FlexMeasures platform. + + :return: True if user has permission, False otherwise. + """ + try: + check_access(FlexMeasuresPlatform.init(), "create-children") + return True + except (Forbidden, Unauthorized): + return False diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 06767abeee..aa948455f4 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -25,7 +25,11 @@ ) from flexmeasures.app import create as create_app -from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE +from flexmeasures.auth.policy import ( + ADMIN_ROLE, + ADMIN_READER_ROLE, + CONSULTANCY_ACCOUNT_ROLE, +) from flexmeasures.data.services.users import create_user from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset from flexmeasures.data.models.data_sources import DataSource @@ -157,10 +161,15 @@ def create_test_accounts(db) -> dict[str, Account]: consultancy_account_role = AccountRole( name="Consultancy", description="A consultancy account" ) + consultancy_account_role = AccountRole( + name=CONSULTANCY_ACCOUNT_ROLE, + description="Consultancy account that can create own client accounts", + ) # Create Consultancy and ConsultancyClient account. # The ConsultancyClient account needs the account id of the Consultancy account so the order is important. consultancy_account = Account( - name="Test Consultancy Account", account_roles=[consultancy_account_role] + name="Test Consultancy Account", + account_roles=[consultancy_account_role, consultancy_account_role], ) db.session.add(consultancy_account) consultancy_client_account_role = AccountRole( diff --git a/flexmeasures/data/schemas/account.py b/flexmeasures/data/schemas/account.py index d38a1093bd..881a799929 100644 --- a/flexmeasures/data/schemas/account.py +++ b/flexmeasures/data/schemas/account.py @@ -1,7 +1,9 @@ from typing import Any from flexmeasures.data import ma -from marshmallow import fields, validates +from marshmallow import Schema, fields, validates, post_load +from flask_security import current_user +from werkzeug.exceptions import Forbidden from flexmeasures.data import db from flexmeasures.data.models.user import Account, AccountRole @@ -12,6 +14,12 @@ with_appcontext_if_needed, ) from flexmeasures.utils.validation_utils import validate_color_hex, validate_url +from flexmeasures.auth.policy import ( + user_has_admin_access, + ACCOUNT_ADMIN_ROLE, + CONSULTANT_ROLE, + CONSULTANCY_ACCOUNT_ROLE, +) class AccountRoleSchema(ma.SQLAlchemySchema): @@ -62,6 +70,194 @@ def validate_logo_url(self, value, **kwargs): raise FMValidationError(str(e)) +def _validate_consultancy_account_id_permissions(value, allow_clearing: bool = False): + """Shared validation logic for consultancy_account_id field. + + Args: + value: The consultancy_account_id value to validate (can be None) + allow_clearing: If True, allows None value after role checks (for PATCH) + If False, None is allowed without additional validation (for POST with defaulting) + + Raises: + Forbidden: When user lacks required permissions + FMValidationError: When account doesn't exist or other validation fails + + Rules: + - Admins can set/clear any consultancy_account_id + - Non-admins need both: + - Account role: Consultancy + - User role: consultant OR account-admin + - Non-admins can only set it to their own account + """ + # Admins can do anything + if user_has_admin_access(current_user, "update"): + return + + # For non-admins, check required roles first (before allowing None or validating value) + if not current_user.account.has_role(CONSULTANCY_ACCOUNT_ROLE): + raise Forbidden( + f"Your account must have the '{CONSULTANCY_ACCOUNT_ROLE}' role " + "to be set as a consultancy account." + ) + + if not ( + current_user.has_role(CONSULTANT_ROLE) + or current_user.has_role(ACCOUNT_ADMIN_ROLE) + ): + raise Forbidden( + f"You must have the '{CONSULTANT_ROLE}' or '{ACCOUNT_ADMIN_ROLE}' " + "role to set a consultancy account." + ) + + # After role checks, None is allowed + if value is None: + return + + # Validate the explicit value provided + consultancy_account = db.session.get(Account, value) + if consultancy_account is None: + raise FMValidationError(f"No account found with id {value}.") + + # Non-admins can only set their own account + if value != current_user.account.id: + raise Forbidden("You can only set consultancy_account_id to your own account.") + + +class AccountCreateSchema(Schema): + """Schema for creating an account via API.""" + + name = fields.String(required=True) + consultancy_account_id = fields.Integer(required=False, allow_none=True) + + @validates("name") + def validate_name(self, value, **kwargs): + if not value.strip(): + raise FMValidationError("Account name cannot be empty.") + + # check if account with this name already exists + existing_account = db.session.execute( + db.select(Account).filter_by(name=value) + ).scalar_one_or_none() + if existing_account: + raise FMValidationError(f"An account with name '{value}' already exists.") + + @validates("consultancy_account_id") + @with_appcontext_if_needed() + def validate_consultancy_account_id(self, value, **kwargs): + """Validate consultancy_account_id field for account creation. + + Uses shared validation logic. For POST requests, None is allowed + and will be defaulted in @post_load. + """ + _validate_consultancy_account_id_permissions(value, allow_clearing=False) + + @post_load + @with_appcontext_if_needed() + def set_consultancy_account_default(self, data, **kwargs): + """Set consultancy_account_id to current user's account for consultants/account-admins. + + This runs after validation, so we know the user has the required roles. + Only applies when consultancy_account_id is None or missing. + """ + if ( + "consultancy_account_id" not in data + or data["consultancy_account_id"] is None + ): + # Admins don't get auto-defaulting + if user_has_admin_access(current_user, "update"): + return data + + # For consultants/account-admins, default to their account + # (validation already confirmed they have the required roles) + if current_user.has_role(CONSULTANT_ROLE) or current_user.has_role( + ACCOUNT_ADMIN_ROLE + ): + data["consultancy_account_id"] = current_user.account.id + + return data + + +class AccountPatchSchema(Schema): + """Schema for updating an account via API.""" + + name = fields.String(required=False) + primary_color = fields.String(required=False, allow_none=True) + secondary_color = fields.String(required=False, allow_none=True) + logo_url = fields.String(required=False, allow_none=True) + consultancy_account_id = fields.Integer(required=False, allow_none=True) + attributes = JSON(required=False) + account_roles = fields.List(fields.Integer(), required=False) + + @validates("primary_color") + def validate_primary_color(self, value, **kwargs): + try: + validate_color_hex(value) + except ValueError as e: + raise FMValidationError(str(e)) + + @validates("secondary_color") + def validate_secondary_color(self, value, **kwargs): + try: + validate_color_hex(value) + except ValueError as e: + raise FMValidationError(str(e)) + + @validates("logo_url") + def validate_logo_url(self, value, **kwargs): + try: + validate_url(value) + except ValueError as e: + raise FMValidationError(str(e)) + + @validates("consultancy_account_id") + @with_appcontext_if_needed() + def validate_consultancy_account_id(self, value, **kwargs): + """Validate consultancy_account_id field for account updates. + + Uses shared validation logic. For PATCH requests, None clears the relationship. + """ + _validate_consultancy_account_id_permissions(value, allow_clearing=True) + + @post_load + @with_appcontext_if_needed() + def transform_account_roles(self, data, **kwargs): + """Transform account_roles from list of IDs to list of AccountRole objects. + + Validates that: + - account_roles is a list of integers + - All role IDs exist in the database + + Raises: + FMValidationError: If validation fails + """ + if "account_roles" not in data: + return data + + raw_roles = data["account_roles"] + + # Validate it's a list of integers + if not isinstance(raw_roles, list) or any( + not isinstance(role_id, int) for role_id in raw_roles + ): + raise FMValidationError("account_roles must be a list of integer IDs.") + + # Resolve IDs to AccountRole objects + resolved_roles = [db.session.get(AccountRole, role_id) for role_id in raw_roles] + + # Check for invalid IDs + invalid_role_ids = [ + role_id + for role_id, db_role in zip(raw_roles, resolved_roles) + if db_role is None + ] + if invalid_role_ids: + raise FMValidationError(f"Invalid account role ID(s): {invalid_role_ids}.") + + # Replace list of IDs with list of AccountRole objects + data["account_roles"] = resolved_roles + return data + + class AccountIdField(MarshmallowClickMixin, fields.Int): """Field that deserializes to an Account and serializes back to an integer.""" diff --git a/flexmeasures/ui/static/css/flexmeasures.css b/flexmeasures/ui/static/css/flexmeasures.css index a128b37dcb..1bf8149a1d 100644 --- a/flexmeasures/ui/static/css/flexmeasures.css +++ b/flexmeasures/ui/static/css/flexmeasures.css @@ -881,10 +881,6 @@ table.dataTable.no-footer { /* extra buffer for bootstrap rows */ .top-buffer { margin-top:15px; } -.dataTables_wrapper { - margin-bottom: 20px; -} - .dataTables_wrapper .paginate_button { outline: none; } @@ -1977,6 +1973,7 @@ body.touched [title]:hover:after { .table-responsive{ padding: 20px; + scrollbar-width: none; } .border-on-click { diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 3f122566b7..0b20e8721e 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1827,10 +1827,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Account1" + "$ref": "#/components/schemas/AccountPatchSchema" }, "example": { "name": "Test Account Updated", + "account_roles": [ + 1, + 3 + ], "primary_color": "#1a3443", "secondary_color": "#f1a122", "logo_url": "https://example.com/logo.png", @@ -2007,6 +2011,47 @@ "tags": [ "Accounts" ] + }, + "post": { + "summary": "Create a new account.", + "description": "Create a new account with a required name.\n\n- Admin users can create accounts.\n- Consultant users can create accounts only if their account has the\n `CONSULTANCY_ACCOUNT_ROLE` account role.\n- For consultant users, the newly created account is automatically\n linked to their own account as consultancy account.\n", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "description": "Account fields for creation.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreateSchema" + }, + "example": { + "name": "New Customer Account", + "consultancy_account_id": 2 + } + } + } + }, + "responses": { + "201": { + "description": "PROCESSED" + }, + "401": { + "description": "UNAUTHORIZED" + }, + "403": { + "description": "INVALID_SENDER" + }, + "422": { + "description": "UNPROCESSABLE_ENTITY" + } + }, + "tags": [ + "Accounts" + ] } }, "/api/v3_0/accounts/{id}/annotations": { @@ -5246,6 +5291,64 @@ }, "additionalProperties": false }, + "AccountCreateSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "consultancy_account_id": { + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "AccountPatchSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_color": { + "type": [ + "string", + "null" + ] + }, + "secondary_color": { + "type": [ + "string", + "null" + ] + }, + "logo_url": { + "type": [ + "string", + "null" + ] + }, + "consultancy_account_id": { + "type": [ + "integer", + "null" + ] + }, + "attributes": {}, + "account_roles": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "additionalProperties": false + }, "AccountAPIQuerySchema": { "type": "object", "properties": { @@ -5947,62 +6050,6 @@ }, "additionalProperties": false }, - "Account1": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "readOnly": true - }, - "name": { - "type": [ - "string", - "null" - ], - "maxLength": 100 - }, - "primary_color": { - "type": [ - "string", - "null" - ], - "maxLength": 7 - }, - "secondary_color": { - "type": [ - "string", - "null" - ], - "maxLength": 7 - }, - "logo_url": { - "type": [ - "string", - "null" - ], - "maxLength": 255 - }, - "attributes": { - "default": "{}" - }, - "account_roles": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AccountRole" - } - }, - "consultancy_account_id": { - "type": [ - "integer", - "null" - ] - } - }, - "required": [ - "name" - ], - "additionalProperties": false - }, "User": { "type": "object", "properties": { diff --git a/flexmeasures/ui/templates/accounts/account.html b/flexmeasures/ui/templates/accounts/account.html index d8dbc81c4e..ad3e7dcd4c 100644 --- a/flexmeasures/ui/templates/accounts/account.html +++ b/flexmeasures/ui/templates/accounts/account.html @@ -171,7 +171,17 @@

Edit {{ account.name }}

-

Account

+
+

Account

+ {% if can_add_client_account %} + + Add client account + + {% endif %} +
Account: {{ account.name }}
diff --git a/flexmeasures/ui/templates/accounts/account_create.html b/flexmeasures/ui/templates/accounts/account_create.html new file mode 100644 index 0000000000..2f80605feb --- /dev/null +++ b/flexmeasures/ui/templates/accounts/account_create.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% set active_page = "accounts" %} +{% block title %} Create account {% endblock %} + +{% block divs %} +
+
+
+
+

Create account

+
+
+ + +
+ + {% if user_is_admin %} +
+ + +
+ {% endif %} + + +
+ + +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/flexmeasures/ui/templates/accounts/accounts.html b/flexmeasures/ui/templates/accounts/accounts.html index 98e2fa2d9b..82ed75fd76 100644 --- a/flexmeasures/ui/templates/accounts/accounts.html +++ b/flexmeasures/ui/templates/accounts/accounts.html @@ -8,8 +8,14 @@
-

All Accounts -

+
+

All Accounts

+ {% if user_can_create_account %} + + Create account + + {% endif %} +
diff --git a/flexmeasures/ui/tests/conftest.py b/flexmeasures/ui/tests/conftest.py index 256c23a7e1..0e0d2960e2 100644 --- a/flexmeasures/ui/tests/conftest.py +++ b/flexmeasures/ui/tests/conftest.py @@ -42,6 +42,24 @@ def as_dummy_account_admin(client): logout(client) +@pytest.fixture(scope="function") +def as_consultant(client): + """Login the consultant user and log them out afterwards.""" + login(client, "test_consultant@seita.nl", "testtest") + yield + logout(client) + + +@pytest.fixture(scope="function") +def as_consultancy_user_without_consultant_access(client): + """Login consultancy user without consultant role and log them out afterwards.""" + login( + client, "test_consultancy_user_without_consultant_access@seita.nl", "testtest" + ) + yield + logout(client) + + @pytest.fixture def public_asset(db, setup_generic_asset_types): """A public asset with no owner account, readable by any logged-in user.""" diff --git a/flexmeasures/ui/tests/test_account_crud.py b/flexmeasures/ui/tests/test_account_crud.py index 73dfe4379a..ac34ffabed 100644 --- a/flexmeasures/ui/tests/test_account_crud.py +++ b/flexmeasures/ui/tests/test_account_crud.py @@ -2,9 +2,13 @@ from flask import url_for from flask_login import current_user +from sqlalchemy import select -from flexmeasures.ui.tests.utils import login, logout +from flexmeasures.data.models.user import AccountRole +from flexmeasures.data.services.users import find_user_by_email +from flexmeasures.auth.policy import CONSULTANCY_ACCOUNT_ROLE +from flexmeasures.ui.tests.utils import login, logout account_api_path = "http://localhost//api/v3_0/accounts" @@ -41,6 +45,124 @@ def test_account_page_breadcrumb(db, client, as_prosumer_user1): assert current_user.account.name.encode() in account_page.data +def _set_consultant_account_role(db, enable: bool): + consultant_account = find_user_by_email("test_consultant@seita.nl").account + role = db.session.execute( + select(AccountRole).filter_by(name=CONSULTANCY_ACCOUNT_ROLE) + ).scalar_one_or_none() + if role is None: + role = AccountRole( + name=CONSULTANCY_ACCOUNT_ROLE, + description="Consultancy account that can create own client accounts", + ) + db.session.add(role) + db.session.flush() + + has_role = consultant_account.has_role(CONSULTANCY_ACCOUNT_ROLE) + if enable and not has_role: + consultant_account.account_roles.append(role) + if not enable and has_role: + consultant_account.account_roles = [ + r + for r in consultant_account.account_roles + if r.name != CONSULTANCY_ACCOUNT_ROLE + ] + db.session.commit() + + +@pytest.mark.parametrize( + "login_fixture, consultant_account_role_enabled, expect_create_button", + [ + ("as_admin", True, True), + ("as_consultant", True, True), + ("as_consultant", False, False), + ("as_prosumer_user1", True, False), + ], +) +def test_accounts_index_create_account_button_visibility( + db, + client, + request, + login_fixture, + consultant_account_role_enabled, + expect_create_button, +): + _set_consultant_account_role(db, consultant_account_role_enabled) + request.getfixturevalue(login_fixture) + + account_page = client.get(url_for("AccountCrudUI:index"), follow_redirects=True) + assert account_page.status_code == 200 + if expect_create_button: + assert b"Create account" in account_page.data + else: + assert b"Create account" not in account_page.data + + +@pytest.mark.parametrize( + "login_fixture, consultant_account_role_enabled, expected_status_code", + [ + ("as_admin", True, 200), + ("as_consultant", True, 200), + ("as_consultant", False, 403), + ("as_prosumer_user1", True, 403), + ], +) +def test_create_account_page_access_control( + db, + client, + request, + login_fixture, + consultant_account_role_enabled, + expected_status_code, +): + _set_consultant_account_role(db, consultant_account_role_enabled) + request.getfixturevalue(login_fixture) + + response = client.get(url_for("AccountCrudUI:new"), follow_redirects=True) + assert response.status_code == expected_status_code + + +def test_account_page_add_client_account_button_for_consultancy_account( + db, client, as_consultant +): + _set_consultant_account_role(db, True) + consultancy_account_id = find_user_by_email("test_consultant@seita.nl").account.id + + account_page = client.get( + url_for("AccountCrudUI:get", account_id=consultancy_account_id), + follow_redirects=True, + ) + assert account_page.status_code == 200 + assert b"Add client account" in account_page.data + + +def test_account_page_no_add_client_account_button_for_non_consultancy_account( + db, client, as_consultant +): + _set_consultant_account_role(db, False) + non_consultancy_account_id = find_user_by_email( + "test_consultant@seita.nl" + ).account.id + + account_page = client.get( + url_for("AccountCrudUI:get", account_id=non_consultancy_account_id), + follow_redirects=True, + ) + assert account_page.status_code == 200 + assert b"Add client account" not in account_page.data + + +def test_account_page_add_client_account_button_for_site_admin(db, client, as_admin): + account_id = find_user_by_email("test_prosumer_user@seita.nl").account.id + + account_page = client.get( + url_for("AccountCrudUI:get", account_id=account_id), + follow_redirects=True, + ) + assert account_page.status_code == 200 + assert b"Add client account" in account_page.data + + def test_account_page_forbidden_for_different_account_user( db, client, setup_accounts, as_dummy_user3 ): diff --git a/flexmeasures/ui/views/accounts.py b/flexmeasures/ui/views/accounts.py index 544011fd23..a58cdb4146 100644 --- a/flexmeasures/ui/views/accounts.py +++ b/flexmeasures/ui/views/accounts.py @@ -1,17 +1,23 @@ from __future__ import annotations +from flask import request from sqlalchemy import select -from werkzeug.exceptions import Forbidden, NotFound, Unauthorized -from flask_classful import FlaskView +from werkzeug.exceptions import Forbidden, Unauthorized, NotFound +from flask_classful import FlaskView, route from flask_security import login_required from flask_security.core import current_user -from flexmeasures.auth.policy import user_has_admin_access, check_access +from flexmeasures.auth.policy import ( + user_can_add_accounts, + user_has_admin_access, + check_access, + FlexMeasuresPlatform, +) from flexmeasures.ui.utils.view_utils import render_flexmeasures_template, ICON_MAPPING from flexmeasures.ui.utils.breadcrumb_utils import get_breadcrumb_info from flexmeasures.data.models.audit_log import AuditLog -from flexmeasures.data.models.user import Account +from flexmeasures.data.models.user import Account, AccountRole from flexmeasures.data.services.accounts import get_accounts, get_audit_log_records from flexmeasures.data import db from flexmeasures.ui.views import ( @@ -28,8 +34,28 @@ class AccountCrudUI(FlaskView): def index(self): """/accounts""" + user_can_create_account = user_can_add_accounts() + return render_flexmeasures_template( "accounts/accounts.html", + user_can_create_account=user_can_create_account, + ) + + @route("/new", methods=["GET"]) + @login_required + def new(self): + """/accounts/new""" + check_access(FlexMeasuresPlatform.init(), "create-children") + user_is_admin = user_has_admin_access(current_user, "read") + potential_consultant_accounts = get_accounts() if user_is_admin else [] + selected_consultancy_account_id = request.args.get( + "consultancy_account_id", default=None, type=int + ) + return render_flexmeasures_template( + "accounts/account_create.html", + user_is_admin=user_is_admin, + accounts=potential_consultant_accounts, + selected_consultancy_account_id=selected_consultancy_account_id, ) @login_required @@ -68,10 +94,22 @@ def get(self, account_id: str): except (Forbidden, Unauthorized): user_can_create_children = False + user_is_admin = user_has_admin_access(current_user, "read") + can_add_client_account = user_can_add_accounts() + + account_role_options = { + role.name: role.id for role in db.session.scalars(select(AccountRole)).all() + } + selected_account_roles = [role.name for role in account.account_roles] + return render_flexmeasures_template( "accounts/account.html", account=account, accounts=potential_consultant_accounts, + user_is_admin=user_is_admin, + can_add_client_account=can_add_client_account, + account_role_options=account_role_options, + selected_account_roles=selected_account_roles, user_can_update_account=user_can_update_account, user_can_create_children=user_can_create_children, can_view_account_auditlog=user_can_view_account_auditlog,