users.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. from __future__ import annotations
  2. from flask_classful import FlaskView, route
  3. from marshmallow import fields
  4. import marshmallow.validate as validate
  5. from sqlalchemy.exc import IntegrityError
  6. from sqlalchemy import and_, select, func, or_
  7. from flask_sqlalchemy.pagination import SelectPagination
  8. from webargs.flaskparser import use_kwargs
  9. from flask_security import current_user, auth_required
  10. from flask_security.recoverable import send_reset_password_instructions
  11. from flask_json import as_json
  12. from werkzeug.exceptions import Forbidden
  13. from flexmeasures.auth.policy import check_access
  14. from flexmeasures.data.models.audit_log import AuditLog
  15. from flexmeasures.data.models.user import User as UserModel, Account
  16. from flexmeasures.api.common.schemas.users import AccountIdField, UserIdField
  17. from flexmeasures.api.common.schemas.search import SearchFilterField
  18. from flexmeasures.api.v3_0.assets import get_accessible_accounts
  19. from flexmeasures.data.queries.users import query_users_by_search_terms
  20. from flexmeasures.data.schemas.account import AccountSchema
  21. from flexmeasures.data.schemas.users import UserSchema
  22. from flexmeasures.data.services.users import (
  23. set_random_password,
  24. remove_cookie_and_token_access,
  25. )
  26. from flexmeasures.auth.decorators import permission_required_for_context
  27. from flexmeasures.data import db
  28. from flexmeasures.utils.time_utils import server_now, naturalized_datetime_str
  29. """
  30. API endpoints to manage users.
  31. Both POST (to create) and DELETE are not accessible via the API, but as CLI functions.
  32. """
  33. # Instantiate schemas outside of endpoint logic to minimize response time
  34. user_schema = UserSchema()
  35. users_schema = UserSchema(many=True)
  36. partial_user_schema = UserSchema(partial=True)
  37. account_schema = AccountSchema()
  38. class UserAPI(FlaskView):
  39. route_base = "/users"
  40. trailing_slash = False
  41. decorators = [auth_required()]
  42. @route("", methods=["GET"])
  43. @use_kwargs(
  44. {
  45. "account": AccountIdField(data_key="account_id", load_default=None),
  46. "include_inactive": fields.Bool(load_default=False),
  47. "page": fields.Int(
  48. required=False, validate=validate.Range(min=1), load_default=None
  49. ),
  50. "per_page": fields.Int(
  51. required=False, validate=validate.Range(min=1), load_default=1
  52. ),
  53. "filter": SearchFilterField(required=False, load_default=None),
  54. "sort_by": fields.Str(
  55. required=False,
  56. load_default=None,
  57. validate=validate.OneOf(["username", "email", "lastLogin", "lastSeen"]),
  58. ),
  59. "sort_dir": fields.Str(
  60. required=False,
  61. load_default=None,
  62. validate=validate.OneOf(["asc", "desc"]),
  63. ),
  64. },
  65. location="query",
  66. )
  67. @as_json
  68. def index(
  69. self,
  70. account: Account,
  71. include_inactive: bool = False,
  72. page: int | None = None,
  73. per_page: int | None = None,
  74. filter: list[str] | None = None,
  75. sort_by: str | None = None,
  76. sort_dir: str | None = None,
  77. ):
  78. """
  79. API endpoint to list all users.
  80. .. :quickref: User; Download user list
  81. This endpoint returns all accessible users.
  82. By default, only active users are returned.
  83. The `account_id` query parameter can be used to filter the users of
  84. a given account.
  85. The `include_inactive` query parameter can be used to also fetch
  86. inactive users.
  87. Accessible users are users in the same account as the current user.
  88. Only admins can use this endpoint to fetch users from a different account (by using the `account_id` query parameter).
  89. **Example response**
  90. An example of one user being returned:
  91. .. sourcecode:: json
  92. [
  93. {
  94. 'active': True,
  95. 'email': 'test_prosumer@seita.nl',
  96. 'account_id': 13,
  97. 'flexmeasures_roles': [1, 3],
  98. 'id': 1,
  99. 'timezone': 'Europe/Amsterdam',
  100. 'username': 'Test Prosumer User'
  101. }
  102. ]
  103. :reqheader Authorization: The authentication token
  104. :reqheader Content-Type: application/json
  105. :resheader Content-Type: application/json
  106. :status 200: PROCESSED
  107. :status 400: INVALID_REQUEST
  108. :status 401: UNAUTHORIZED
  109. :status 403: INVALID_SENDER
  110. :status 422: UNPROCESSABLE_ENTITY
  111. """
  112. if account is not None:
  113. check_access(account, "read")
  114. accounts = [account]
  115. else:
  116. accounts = get_accessible_accounts()
  117. filter_statement = UserModel.account_id.in_([a.id for a in accounts])
  118. if include_inactive is False:
  119. filter_statement = and_(filter_statement, UserModel.active.is_(True))
  120. query = query_users_by_search_terms(
  121. search_terms=filter, filter_statement=filter_statement
  122. )
  123. if sort_by is not None and sort_dir is not None:
  124. valid_sort_columns = {
  125. "username": UserModel.username,
  126. "email": UserModel.email,
  127. "lastLogin": UserModel.last_login_at,
  128. "lastSeen": UserModel.last_seen_at,
  129. }
  130. query = query.order_by(
  131. valid_sort_columns[sort_by].asc()
  132. if sort_dir == "asc"
  133. else valid_sort_columns[sort_by].desc()
  134. )
  135. if page is not None:
  136. num_records = db.session.scalar(
  137. select(func.count(UserModel.id)).where(filter_statement)
  138. )
  139. paginated_users: SelectPagination = db.paginate(
  140. query, per_page=per_page, page=page
  141. )
  142. users_response: list = [
  143. {
  144. **user_schema.dump(user),
  145. "account": account_schema.dump(user.account),
  146. "flexmeasures_roles": [
  147. role.name for role in user.flexmeasures_roles
  148. ],
  149. "last_login_at": naturalized_datetime_str(user.last_login_at),
  150. "last_seen_at": naturalized_datetime_str(user.last_seen_at),
  151. }
  152. for user in paginated_users.items
  153. ]
  154. response: dict | list = {
  155. "data": users_response,
  156. "num-records": num_records,
  157. "filtered-records": paginated_users.total,
  158. }
  159. else:
  160. users = db.session.execute(query).scalars().all()
  161. response = [
  162. {
  163. **user_schema.dump(user),
  164. "account": account_schema.dump(user.account),
  165. "flexmeasures_roles": [
  166. role.name for role in user.flexmeasures_roles
  167. ],
  168. "last_login_at": naturalized_datetime_str(user.last_login_at),
  169. "last_seen_at": naturalized_datetime_str(user.last_seen_at),
  170. }
  171. for user in users
  172. ]
  173. return response, 200
  174. @route("/<id>")
  175. @use_kwargs({"user": UserIdField(data_key="id")}, location="path")
  176. @permission_required_for_context("read", ctx_arg_name="user")
  177. @as_json
  178. def get(self, id: int, user: UserModel):
  179. """API endpoint to get a user.
  180. .. :quickref: User; Get a user
  181. This endpoint gets a user.
  182. Only admins or the members of the same account can use this endpoint.
  183. **Example response**
  184. .. sourcecode:: json
  185. {
  186. 'account_id': 1,
  187. 'active': True,
  188. 'email': 'test_prosumer@seita.nl',
  189. 'flexmeasures_roles': [1, 3],
  190. 'id': 1,
  191. 'timezone': 'Europe/Amsterdam',
  192. 'username': 'Test Prosumer User'
  193. }
  194. :reqheader Authorization: The authentication token
  195. :reqheader Content-Type: application/json
  196. :resheader Content-Type: application/json
  197. :status 200: PROCESSED
  198. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  199. :status 401: UNAUTHORIZED
  200. :status 403: INVALID_SENDER
  201. :status 422: UNPROCESSABLE_ENTITY
  202. """
  203. return user_schema.dump(user), 200
  204. @route("/<id>", methods=["PATCH"])
  205. @use_kwargs(partial_user_schema)
  206. @use_kwargs({"user": UserIdField(data_key="id")}, location="path")
  207. @permission_required_for_context("update", ctx_arg_name="user")
  208. @as_json
  209. def patch(self, id: int, user: UserModel, **user_data): # noqa C901
  210. """API endpoint to patch user data.
  211. .. :quickref: User; Patch data for an existing user
  212. This endpoint sets data for an existing user.
  213. It has to be used by the user themselves, admins, consultant or account-admins (of the same account).
  214. Any subset of user fields can be sent.
  215. If the user is not an (account-)admin, they can only edit a few of their own fields.
  216. 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.
  217. The following fields are not allowed to be updated at all:
  218. - id
  219. - account_id
  220. **Example request**
  221. .. sourcecode:: json
  222. {
  223. "active": false,
  224. }
  225. **Example response**
  226. The following user fields are returned:
  227. .. sourcecode:: json
  228. {
  229. 'account_id': 1,
  230. 'active': True,
  231. 'email': 'test_prosumer@seita.nl',
  232. 'flexmeasures_roles': [1, 3],
  233. 'id': 1,
  234. 'timezone': 'Europe/Amsterdam',
  235. 'username': 'Test Prosumer User'
  236. }
  237. :reqheader Authorization: The authentication token
  238. :reqheader Content-Type: application/json
  239. :resheader Content-Type: application/json
  240. :status 200: UPDATED
  241. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  242. :status 401: UNAUTHORIZED
  243. :status 403: INVALID_SENDER
  244. :status 422: UNPROCESSABLE_ENTITY
  245. """
  246. allowed_fields = [
  247. "email",
  248. "username",
  249. "active",
  250. "timezone",
  251. "flexmeasures_roles",
  252. ]
  253. audit_event = "" # we audit-log relevant changes
  254. for k, v in [(k, v) for k, v in user_data.items() if k in allowed_fields]:
  255. if current_user.id == user.id and k in ("active", "flexmeasures_roles"):
  256. raise Forbidden(
  257. "Users who edit themselves cannot edit security-sensitive fields."
  258. )
  259. # if flexmeasures_roles is not empty, check if the user can modify the role
  260. if k == "flexmeasures_roles" and (v or len(v) == 0):
  261. from flexmeasures.auth.policy import can_modify_role
  262. current_roles = set(user.flexmeasures_roles)
  263. new_roles = set(v)
  264. roles_being_removed = current_roles - new_roles
  265. for role in roles_being_removed:
  266. if not can_modify_role(current_user, [role]):
  267. raise Forbidden(
  268. f"You are not allowed to remove ({role.name}) role from this user."
  269. )
  270. if roles_being_removed:
  271. audit_event += f"Removed role(s): [{','.join([r.name for r in roles_being_removed])}]."
  272. roles_being_added = new_roles - current_roles
  273. for role in roles_being_added:
  274. if not can_modify_role(current_user, [role]):
  275. raise Forbidden(
  276. f"You are not allowed to add ({role.name}) role to this user."
  277. )
  278. if roles_being_added:
  279. audit_event += f"Added role(s): [{','.join([r.name for r in roles_being_added])}]."
  280. setattr(user, k, v)
  281. if k == "active" and v is False:
  282. remove_cookie_and_token_access(user)
  283. if k == "active":
  284. audit_event += f"Active status set to '{v}'."
  285. if audit_event:
  286. user_audit_log = create_user_audit_log(audit_event, user)
  287. db.session.add(user_audit_log)
  288. db.session.add(user)
  289. try:
  290. db.session.commit()
  291. except IntegrityError as ie:
  292. return (
  293. dict(message="Duplicate user already exists", detail=ie._message()),
  294. 400,
  295. )
  296. return user_schema.dump(user), 200
  297. @route("/<id>/password-reset", methods=["PATCH"])
  298. @use_kwargs({"user": UserIdField(data_key="id")}, location="path")
  299. @permission_required_for_context("update", ctx_arg_name="user")
  300. @as_json
  301. def reset_user_password(self, id: int, user: UserModel):
  302. """API endpoint to reset the user's current password, cookies and auth tokens, and to email a password reset link to the user.
  303. .. :quickref: User; Password reset
  304. Reset the user's password, and send them instructions on how to reset the password.
  305. This endpoint is useful from a security standpoint, in case of worries the password might be compromised.
  306. It sets the current password to something random, invalidates cookies and auth tokens,
  307. and also sends an email for resetting the password to the user.
  308. Users can reset their own passwords. Only admins can use this endpoint to reset passwords of other users.
  309. :reqheader Authorization: The authentication token
  310. :reqheader Content-Type: application/json
  311. :resheader Content-Type: application/json
  312. :status 200: PROCESSED
  313. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  314. :status 401: UNAUTHORIZED
  315. :status 403: INVALID_SENDER
  316. :status 422: UNPROCESSABLE_ENTITY
  317. """
  318. set_random_password(user)
  319. remove_cookie_and_token_access(user)
  320. send_reset_password_instructions(user)
  321. # commit only if sending instructions worked, as well
  322. db.session.commit()
  323. @route("/<id>/auditlog")
  324. @use_kwargs({"user": UserIdField(data_key="id")}, location="path")
  325. @permission_required_for_context(
  326. "read",
  327. ctx_arg_name="user",
  328. pass_ctx_to_loader=True,
  329. ctx_loader=AuditLog.user_table_acl,
  330. )
  331. @use_kwargs(
  332. {
  333. "page": fields.Int(
  334. required=False, validate=validate.Range(min=1), load_default=None
  335. ),
  336. "per_page": fields.Int(
  337. required=False, validate=validate.Range(min=1), load_default=10
  338. ),
  339. "filter": SearchFilterField(required=False, load_default=None),
  340. "sort_by": fields.Str(
  341. required=False,
  342. load_default=None,
  343. validate=validate.OneOf(["event_datetime"]),
  344. ),
  345. "sort_dir": fields.Str(
  346. required=False,
  347. load_default=None,
  348. validate=validate.OneOf(["asc", "desc"]),
  349. ),
  350. },
  351. location="query",
  352. )
  353. @as_json
  354. def auditlog(
  355. self,
  356. id: int,
  357. user: UserModel,
  358. page: int | None = None,
  359. per_page: int | None = None,
  360. filter: list[str] | None = None,
  361. sort_by: str | None = None,
  362. sort_dir: str | None = None,
  363. ):
  364. """API endpoint to get history of user actions.
  365. The endpoint is paginated and supports search filters.
  366. - If the `page` parameter is not provided, all audit logs are returned paginated by `per_page` (default is 10).
  367. - If a `page` parameter is provided, the response will be paginated, showing a specific number of audit logs per page as defined by `per_page` (default is 10).
  368. - If a search 'filter' is provided, the response will filter out audit logs where each search term is either present in the event or active user name.
  369. The response schema for pagination is inspired by https://datatables.net/manual/server-side
  370. **Example response**
  371. .. sourcecode:: json
  372. {
  373. "data" : [
  374. {
  375. 'event': 'User test user deleted',
  376. 'event_datetime': '2021-01-01T00:00:00',
  377. 'active_user_name': 'Test user',
  378. }
  379. ],
  380. "num-records" : 1,
  381. "filtered-records" : 1
  382. }
  383. :reqheader Authorization: The authentication token
  384. :reqheader Content-Type: application/json
  385. :resheader Content-Type: application/json
  386. :status 200: PROCESSED
  387. :status 400: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
  388. :status 401: UNAUTHORIZED
  389. :status 403: INVALID_SENDER
  390. :status 422: UNPROCESSABLE_ENTITY
  391. """
  392. query_statement = AuditLog.affected_user_id == user.id
  393. query = select(AuditLog).filter(query_statement)
  394. if filter:
  395. search_terms = filter[0].split(" ")
  396. query = query.filter(
  397. or_(
  398. *[AuditLog.event.ilike(f"%{term}%") for term in search_terms],
  399. *[
  400. AuditLog.active_user_name.ilike(f"%{term}%")
  401. for term in search_terms
  402. ],
  403. )
  404. )
  405. if sort_by is not None and sort_dir is not None:
  406. valid_sort_columns = {"event_datetime": AuditLog.event_datetime}
  407. query = query.order_by(
  408. valid_sort_columns[sort_by].asc()
  409. if sort_dir == "asc"
  410. else valid_sort_columns[sort_by].desc()
  411. )
  412. if page is None:
  413. audit_logs = db.session.execute(query).scalars().all()
  414. response = [
  415. {
  416. "event": audit_log.event,
  417. "event_datetime": naturalized_datetime_str(
  418. audit_log.event_datetime
  419. ),
  420. "active_user_name": audit_log.active_user_name,
  421. "active_user_id": audit_log.active_user_id,
  422. }
  423. for audit_log in audit_logs
  424. ]
  425. else:
  426. select_pagination: SelectPagination = db.paginate(
  427. query, per_page=per_page, page=page
  428. )
  429. num_records = db.session.scalar(
  430. select(func.count(AuditLog.id)).where(query_statement)
  431. )
  432. audit_logs_response = [
  433. {
  434. "event": audit_log.event,
  435. "event_datetime": naturalized_datetime_str(
  436. audit_log.event_datetime
  437. ),
  438. "active_user_name": audit_log.active_user_name,
  439. "active_user_id": audit_log.active_user_id,
  440. }
  441. for audit_log in select_pagination.items
  442. ]
  443. response = {
  444. "data": audit_logs_response,
  445. "num-records": num_records,
  446. "filtered-records": select_pagination.total,
  447. }
  448. return response, 200
  449. def create_user_audit_log(audit_event: str, user: UserModel):
  450. """
  451. Create audit log entry for changes on the user
  452. """
  453. active_user_id, active_user_name = None, None
  454. if hasattr(current_user, "id"):
  455. active_user_id, active_user_name = (
  456. current_user.id,
  457. current_user.username,
  458. )
  459. return AuditLog(
  460. event_datetime=server_now(),
  461. event=audit_event,
  462. active_user_id=active_user_id,
  463. active_user_name=active_user_name,
  464. affected_user_id=user.id,
  465. affected_account_id=user.account_id,
  466. )