Skip to content

Commit d54024d

Browse files
nhoeningFlix6x
andauthored
refactor the auth for user role editing (#2228)
* refactor the auth for user role editing, clearer and explicitly about the roles we protect Signed-off-by: Nicolas Höning <nicolas@seita.nl> * add changelog entry Signed-off-by: Nicolas Höning <nicolas@seita.nl> * reformulate with False as default Signed-off-by: Nicolas Höning <nicolas@seita.nl> * remove extra changelog entry Signed-off-by: Nicolas Höning <nicolas@seita.nl> * simplify the loop to check roles - no collection just returning False if we encounter a disallowed entry Signed-off-by: Nicolas Höning <nicolas@seita.nl> * move import Signed-off-by: Nicolas Höning <nicolas@seita.nl> * run a regression test case where the first new role is forbidden and the last new role is allowed Signed-off-by: Nicolas Höning <nicolas@seita.nl> * docs: merge changelog listings for v0.33.1 Signed-off-by: F.N. Claessen <claessen@seita.nl> * docs: add release date for v0.33.1 Signed-off-by: F.N. Claessen <claessen@seita.nl> * refactor: consistent nesting Signed-off-by: F.N. Claessen <claessen@seita.nl> --------- Signed-off-by: Nicolas Höning <nicolas@seita.nl> Signed-off-by: F.N. Claessen <claessen@seita.nl> Co-authored-by: F.N. Claessen <claessen@seita.nl>
1 parent 4de21d4 commit d54024d

7 files changed

Lines changed: 99 additions & 28 deletions

File tree

documentation/changelog.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,20 @@ Bugfixes
3434
* Allow root assets belonging to different accounts to share the same name, while keeping asset names unique among root assets within the same account and among children of the same parent [see `PR #2226 <https://www.github.com/FlexMeasures/flexmeasures/pull/2226>`_]
3535

3636

37-
v0.33.1 | July XX, 2026
37+
v0.33.1 | July 1, 2026
3838
============================
3939

4040
Bugfixes
4141
-----------
4242
* Allow flex-model and flex-context to be missing from scheduling requests, because by now the whole flex-config can be defined on assets (in the db) instead [see `PR #2237 <https://www.github.com/FlexMeasures/flexmeasures/pull/2237>`_]
43+
* Improve auth checks on editing user roles [see `PR #2228 <https://www.github.com/FlexMeasures/flexmeasures/pull/2228>`_]
4344
* Fix Chart Point sessions chart [see `PR #2259 <https://www.github.com/FlexMeasures/flexmeasures/pull/2259>`_]
4445

4546
Infrastructure / Support
4647
------------------------
4748
* Filter handled untrusted-host ``SecurityError`` events out of Sentry, alongside the existing 404 filtering [see `PR #2257 <https://www.github.com/FlexMeasures/flexmeasures/pull/2257>`_]
4849

4950

50-
5151
v0.33.0 | June 1, 2026
5252
============================
5353

flexmeasures/api/v3_0/users.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from flask_security.recoverable import send_reset_password_instructions
1010
from flask_json import as_json
1111
from werkzeug.exceptions import Forbidden
12-
from flexmeasures.auth.policy import check_access
12+
from flexmeasures.auth.policy import can_modify_role, check_access
1313

1414
from flexmeasures.data.models.audit_log import AuditLog
1515
from flexmeasures.data.models.user import User as UserModel, Account
@@ -393,7 +393,7 @@ def patch(self, id: int, user: UserModel, **user_data): # noqa C901
393393
It has to be used by the user themselves, admins, consultant or account-admins (of the same account).
394394
Any subset of user fields can be sent.
395395
If the user is not an (account-)admin, they can only edit a few of their own fields.
396-
User roles cannot be updated by everyone - it requires certain access levels (roles, account), with the general rule that you need a higher access level than the role being updated.
396+
User role updates require explicit permission for every role being added or removed, generally by a user with a higher access level than the role being updated. Unsupported, unknown, or otherwise unresolved roles are denied.
397397
398398
The following fields are not allowed to be updated at all:
399399
- id
@@ -460,8 +460,6 @@ def patch(self, id: int, user: UserModel, **user_data): # noqa C901
460460
)
461461
# if flexmeasures_roles is not empty, check if the user can modify the role
462462
if k == "flexmeasures_roles" and (v or len(v) == 0):
463-
from flexmeasures.auth.policy import can_modify_role
464-
465463
current_roles = set(user.flexmeasures_roles)
466464
new_roles = set(v)
467465

flexmeasures/auth/policy.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def check_account_role(user, principal: str) -> bool:
200200
return False
201201

202202

203-
def can_modify_role(
203+
def can_modify_role( # noqa: C901
204204
user,
205205
roles_to_modify,
206206
modified_user,
@@ -210,7 +210,7 @@ def can_modify_role(
210210
:param user: The current attempting to modify a role.
211211
:param roles_to_modify: A list of roles to modify - can be a Role or a role ID.
212212
:param modified_user: The user whose roles are being modified.
213-
:return: True if the user can modify the roles, False otherwise.
213+
:return: True if the user can modify each of the roles, False otherwise.
214214
215215
The roles are:
216216
- admin: can only be changed in CLI / directly in the DB, so not here
@@ -219,23 +219,50 @@ def can_modify_role(
219219
- consultant: can be added and removed by admins and account-admins (in same account)
220220
221221
"""
222-
222+
roles = []
223223
for role in roles_to_modify:
224224
if isinstance(role, int):
225225
from flexmeasures.data.models.user import Role
226226

227-
role = current_app.db.session.get(Role, role)
228-
229-
if role is not None:
230-
if role.name != ADMIN_ROLE and user.has_role(ADMIN_ROLE):
231-
return True # admin can do all changes, aside from admin status
232-
if role.name == ACCOUNT_ADMIN_ROLE and user.has_role(CONSULTANT_ROLE):
233-
if modified_user.account.consultancy_account is not None:
234-
if user.account.id == modified_user.account.consultancy_account.id:
235-
return True
236-
if role.name == CONSULTANT_ROLE and user.has_role(ACCOUNT_ADMIN_ROLE):
237-
if user.account.id and modified_user.account.id:
238-
if user.account.id == modified_user.account.id:
239-
return True
240-
241-
return False
227+
roles.append(current_app.db.session.get(Role, role))
228+
else:
229+
roles.append(role)
230+
231+
if not roles:
232+
return False
233+
234+
for role in roles:
235+
if role is None:
236+
return False
237+
if role.name == ADMIN_ROLE:
238+
# Nobody can do this here, only in CLI or directly in the DB.
239+
return False
240+
if role.name == ADMIN_READER_ROLE:
241+
# only admins can change admin-reader status
242+
if user.has_role(ADMIN_ROLE):
243+
continue
244+
return False
245+
if role.name == ACCOUNT_ADMIN_ROLE:
246+
# admins and consultants can do this
247+
if user.has_role(ADMIN_ROLE):
248+
continue
249+
if (
250+
modified_user.account.consultancy_account is not None
251+
and user.has_role(CONSULTANT_ROLE)
252+
and user.account.id == modified_user.account.consultancy_account.id
253+
):
254+
continue
255+
return False
256+
if role.name == CONSULTANT_ROLE:
257+
# admins and account-admins can do this
258+
if user.has_role(ADMIN_ROLE):
259+
continue
260+
if (
261+
user.has_role(ACCOUNT_ADMIN_ROLE)
262+
and user.account.id == modified_user.account.id
263+
):
264+
continue
265+
return False
266+
return False
267+
268+
return True

flexmeasures/auth/tests/test_principal_matching.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from __future__ import annotations
22

3+
from types import SimpleNamespace
4+
35
import pytest
46

5-
from flexmeasures.auth.policy import user_matches_principals, can_modify_role
7+
from flexmeasures.auth.policy import (
8+
CONSULTANT_ROLE,
9+
can_modify_role,
10+
user_matches_principals,
11+
)
612

713

814
class MockAccount:
@@ -194,3 +200,41 @@ def test_can_modify_role(
194200
assert (
195201
can_modify_role(mock_user, roles_to_modify, modified_user) == can_modify_roles
196202
)
203+
204+
205+
@pytest.mark.parametrize(
206+
"mock_user, modified_user, roles_to_modify",
207+
[
208+
# Empty role changes are not explicitly allowed.
209+
(
210+
make_mock_user(19, ["consultant"], 1, []),
211+
make_mock_user(20, ["account-admin"], 2, [], 1),
212+
[],
213+
),
214+
# None is not an explicitly supported role.
215+
(
216+
make_mock_user(19, ["consultant"], 1, []),
217+
make_mock_user(21, ["account-admin"], 2, [], 1),
218+
[None],
219+
),
220+
# Unsupported roles must not be allowed by falling through supported checks.
221+
(
222+
make_mock_user(19, ["consultant"], 1, []),
223+
make_mock_user(22, [], 2, [], 1),
224+
[SimpleNamespace(name="unsupported-role")],
225+
),
226+
# Every requested role must be explicitly allowed, so one unsupported role denies the whole change.
227+
(
228+
make_mock_user(19, ["admin"], 1, []),
229+
make_mock_user(23, ["consultant"], 1, []),
230+
[
231+
SimpleNamespace(name="unsupported-role"),
232+
SimpleNamespace(name=CONSULTANT_ROLE),
233+
],
234+
),
235+
],
236+
)
237+
def test_can_modify_role_denies_unexplicit_role_changes(
238+
db, setup_roles_users, mock_user, modified_user, roles_to_modify
239+
):
240+
assert can_modify_role(mock_user, roles_to_modify, modified_user) is False

flexmeasures/data/models/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ def __acl__(self):
290290
"""
291291
Within the same account, everyone can read. Consultants as well.
292292
Only the user themselves, consultants or account-admins can edit their user record.
293+
Check policy.can_modify_role() for special treatment of roles.
293294
Creation and deletion are left to site admins in CLI.
294295
"""
295296
return {

flexmeasures/ui/static/openapi-specs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2251,7 +2251,7 @@
22512251
},
22522252
"patch": {
22532253
"summary": "Update a user.",
2254-
"description": "This endpoint sets data for an existing user.\nIt has to be used by the user themselves, admins, consultant or account-admins (of the same account).\nAny subset of user fields can be sent.\nIf the user is not an (account-)admin, they can only edit a few of their own fields.\nUser roles cannot be updated by everyone - it requires certain access levels (roles, account), with the general rule that you need a higher access level than the role being updated.\n\nThe following fields are not allowed to be updated at all:\n- id\n- account_id\n",
2254+
"description": "This endpoint sets data for an existing user.\nIt has to be used by the user themselves, admins, consultant or account-admins (of the same account).\nAny subset of user fields can be sent.\nIf the user is not an (account-)admin, they can only edit a few of their own fields.\nUser role updates require explicit permission for every role being added or removed, generally by a user with a higher access level than the role being updated. Unsupported, unknown, or otherwise unresolved roles are denied.\n\nThe following fields are not allowed to be updated at all:\n- id\n- account_id\n",
22552255
"security": [
22562256
{
22572257
"ApiKeyAuth": []

flexmeasures/ui/views/users/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from werkzeug.exceptions import Forbidden, Unauthorized
77
from sqlalchemy import select
88

9-
from flexmeasures.auth.policy import check_access
9+
from flexmeasures.auth.policy import check_access, ADMIN_ROLE
1010
from flexmeasures.data import db
1111
from flexmeasures.data.models.audit_log import AuditLog
1212
from flexmeasures.data.models.user import User, Role, Account
@@ -43,7 +43,8 @@ def render_user(user: User | None, msg: str | None = None):
4343

4444
roles = {}
4545
for role in db.session.scalars(select(Role)).all():
46-
roles[role.name] = role.id
46+
if role.name != ADMIN_ROLE:
47+
roles[role.name] = role.id
4748

4849
user_roles = []
4950
if user is not None:

0 commit comments

Comments
 (0)