Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4bac1ff
Feat: implement account creation functionality with UI and API integr…
joshuaunity May 5, 2026
b278a99
Feat: improve account creation validation for unique names and non-em…
joshuaunity May 15, 2026
0f3ca03
ref: improved account form UX with color picker additions
joshuaunity May 15, 2026
e99fa0e
chore: add changelog entry
joshuaunity May 15, 2026
25504e2
Merge branch 'main' into feat/creata-accounts-UI
joshuaunity May 15, 2026
bb4b466
docs: improve changelog entry to mention both API and UI for account …
Copilot May 15, 2026
198a092
chore: update accoutn role name
joshuaunity May 18, 2026
44e3a96
ref: move advanced color picker to account edit form and add consulta…
joshuaunity May 18, 2026
9bd8064
feat: enhance account UI with 'Add client account' button for consult…
joshuaunity May 18, 2026
2707f49
feat: add account patch schema and enhance account roles management i…
joshuaunity May 18, 2026
21cf7ae
feat: enhance security documentation on account roles and their manag…
joshuaunity May 18, 2026
64f0be0
chore: modify changelog
joshuaunity May 18, 2026
845514d
ref: strip down permission guard
joshuaunity May 18, 2026
8c0f5bb
Merge branch 'feat/creata-accounts-UI' of github.com:FlexMeasures/fle…
joshuaunity May 18, 2026
7cf7783
feat: clarify consultancy account functionality and user roles in sec…
joshuaunity May 18, 2026
e30ab32
Update flexmeasures/auth/policy.py
joshuaunity May 19, 2026
a5e0f85
Update flexmeasures/auth/policy.py
joshuaunity May 19, 2026
879c811
fix: fixed failing test using wrong user
joshuaunity May 20, 2026
a3e83a0
chore: udpate docs
joshuaunity May 21, 2026
39559c8
fix: removed double scroolbar in users and accounts tables
joshuaunity May 21, 2026
c30938b
feat: add button to create client accounts in account details
joshuaunity May 21, 2026
69845ab
fix: remove redundant error handling for account creation
joshuaunity May 22, 2026
3d518f4
Merge branch 'main' into feat/creata-accounts-UI
joshuaunity Jun 3, 2026
db9bac2
fix: reorder inheritance in AccountIdField for clarity
joshuaunity Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ New features
-------------
* Added API and UI support for copying assets and their subtrees [see `PR #2017 <https://www.github.com/FlexMeasures/flexmeasures/pull/2017>`_ and `PR #2120 <https://www.github.com/FlexMeasures/flexmeasures/pull/2120>`_]
* Improve UX after deleting a child asset through the UI [see `PR #2119 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2083>`_ and `PR #2151 <https://www.github.com/FlexMeasures/flexmeasures/pull/2151>`_]
* Support sensor references for efficiency fields in storage flex-models [see `PR #2142 <https://www.github.com/FlexMeasures/flexmeasures/pull/2142>`_]
* Introduce the ``consumption`` and ``production`` flex-model fields for the ``StorageScheduler`` to save schedules to [see `PR #2190 <https://www.github.com/FlexMeasures/flexmeasures/pull/2190>`_ and `PR #2213 <https://www.github.com/FlexMeasures/flexmeasures/pull/2213>`_]
Expand Down
14 changes: 7 additions & 7 deletions documentation/concepts/security_auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Comment thread
joshuaunity marked this conversation as resolved.

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.
8 changes: 7 additions & 1 deletion flexmeasures/api/v3_0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
]
Expand Down
102 changes: 95 additions & 7 deletions flexmeasures/api/v3_0/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,38 @@
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,
CONSULTANT_ROLE,
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
from flexmeasures.data.models.user import Account, User
from flexmeasures.data.models.user import Account, User, AccountRole
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

"""
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()


Expand Down Expand Up @@ -178,6 +186,61 @@ 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
"""

if current_user.has_role(CONSULTANT_ROLE):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also check if the account has that "Consultancy " role.

And this should only happen if no consultancy_account_id is sent in.
(if one is sent in, the schema should check it before we get here, see my comment there)

account_data["consultancy_account_id"] = current_user.account.id

account = Account(**account_data)
db.session.add(account)
db.session.commit()

return account_schema.dump(account), 201

@route("/<id>", methods=["GET"])
@use_kwargs({"account": AccountIdField(data_key="id")}, location="path")
@permission_required_for_context("read", ctx_arg_name="account")
Expand Down Expand Up @@ -259,9 +322,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'
Expand Down Expand Up @@ -326,7 +390,31 @@ def patch(self, account_data: dict, id: int, account: Account):
"logo_url",
"consultancy_account_id",
"attributes",
"account_roles",
]

if "account_roles" in account_data:
raw_roles = account_data["account_roles"]
if not isinstance(raw_roles, list) or any(
not isinstance(role_id, int) for role_id in raw_roles
):
return {"errors": ["account_roles must be a list of integer IDs."]}, 422

resolved_roles = [
db.session.get(AccountRole, role_id) for role_id in raw_roles
]
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:
return {
"errors": [f"Invalid account role ID(s): {invalid_role_ids}."]
}, 422

account_data["account_roles"] = resolved_roles

modified_fields = {
field: getattr(account, field)
for field in fields_to_check
Expand Down
96 changes: 96 additions & 0 deletions flexmeasures/api/v3_0/tests/test_accounts_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -283,3 +286,96 @@ 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

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


@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
23 changes: 23 additions & 0 deletions flexmeasures/auth/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@

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
Expand Down Expand Up @@ -82,6 +86,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
Expand Down
13 changes: 11 additions & 2 deletions flexmeasures/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading