Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ACL-based auth policy and the @permission_required_for_context decorator. For now used with /users API #234

Merged
merged 24 commits into from Nov 24, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ec93cc7
first implementation of ACL-based auth policy and the @permission_req…
nhoening Nov 9, 2021
977dc82
make GET /users and PATCH /user/<id> work. Inn the process simplifyin…
nhoening Nov 9, 2021
cab6083
resolve circular import
nhoening Nov 9, 2021
16ea49c
always log the authorization failure for debugging purposes
nhoening Nov 9, 2021
e46c9f0
adapt UI crud so it lists users from all accounts again after API sim…
nhoening Nov 9, 2021
158a945
check for valid permissions and allow the ADMIN_READER role to read
nhoening Nov 9, 2021
c33f982
separate checking admin access and principals within policy, add test…
nhoening Nov 10, 2021
d65f85f
changelog entry
nhoening Nov 10, 2021
a6acf8d
fix test
nhoening Nov 10, 2021
8734e8d
Add test for multiple roles and multiple account roles
Flix6x Nov 11, 2021
1da1a6e
smaller haul from review
nhoening Nov 15, 2021
fff7705
replace deprecated marshmallow parameters
nhoening Nov 16, 2021
0a0c250
explicitly require marshmallow>3 as we use recent parameters (app.txt…
nhoening Nov 16, 2021
9588501
replace load_account with Marshmallow field; enable the permission-ch…
nhoening Nov 16, 2021
0ef64a1
move load_user factory to marshmallow field, as well; refactor permis…
nhoening Nov 16, 2021
2aaeb70
update documentation
nhoening Nov 17, 2021
7bb46e8
permissions: use update over write (-> CRUD)
nhoening Nov 17, 2021
c6f8300
use @auth_required in place of @login_required in non-user-facing end…
nhoening Nov 17, 2021
58e6ab5
doc improvements from review
nhoening Nov 20, 2021
670bc1d
nicer way to state conditions for account roles decorators
nhoening Nov 20, 2021
8637d48
Typo
Flix6x Nov 20, 2021
6f4af62
Auth decorators raise 403/401, simplify invalid_sender
nhoening Nov 22, 2021
fe84e6e
Add type annotation
Flix6x Nov 23, 2021
782569b
nicer error messages in auth decorators
nhoening Nov 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Expand Up @@ -15,6 +15,7 @@ Bugfixes
Infrastructure / Support
----------------------
* Account-based authorization, incl. new decorators for endpoints [see `PR #210 <http://www.github.com/SeitaBV/flexmeasures/pull/210>`_]
* Central authorization policy which lets models codify who can do what (permission-based) and relieve API endpoints from this [see `PR #234 <http://www.github.com/SeitaBV/flexmeasures/pull/234>`_]
nhoening marked this conversation as resolved.
Show resolved Hide resolved
* Improve data specification for forecasting models using timely-beliefs data [see `PR #154 <http://www.github.com/SeitaBV/flexmeasures/pull/154>`_]


Expand Down
120 changes: 120 additions & 0 deletions flexmeasures/api/common/factories.py
@@ -0,0 +1,120 @@
from functools import wraps

from flask import current_app, abort
from flask_security import current_user
from flask_json import as_json

from flexmeasures.data.models.user import User as UserModel, Account as AccountModel
from flexmeasures.api.common.responses import required_info_missing

"""
Decorator factories to load objects from ID parameters.
"""


def load_account(param_location="path"):
"""Decorator which loads an account by the Id expected in the path.
nhoening marked this conversation as resolved.
Show resolved Hide resolved
Raises 400 if that is not possible due to wrong parameters.
Raises 404 if account is not found.
Example:

@app.route('/account/<id>')
@load_account
def get_account(account):
return account_schema.dump(account), 200

The route must specify one parameter ― id.
"""

def wrapper(fn):
@wraps(fn)
@as_json
def decorated_endpoint(*args, **kwargs):

args = list(args)
if len(args) == 0:
current_app.logger.warning("Request missing account_id.")
return required_info_missing(["account_id"])

account_id = None
if param_location == "path":
try:
id = int(args[0])
nhoening marked this conversation as resolved.
Show resolved Hide resolved
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
args = args[1:]
except ValueError:
current_app.logger.warning(
"Cannot parse account_id argument from request."
)
return required_info_missing(
["account_id"], "Cannot parse ID arg as int."
)
elif param_location == "query":
try:
account_id = args[0]["account_id"]
except KeyError:
if current_user.is_anonymous:
raise abort(401, "Cannot load account of anonymous user.")
account_id = current_user.account.id
else:
return required_info_missing(
["account_id"], f"Param location {param_location} is unknown."
)
account: AccountModel = AccountModel.query.filter_by(
id=int(account_id)
).one_or_none()

if account is None:
raise abort(404, f"Account {id} not found")

return fn(account, *args, **kwargs)

return decorated_endpoint

return wrapper


def load_user():
"""Decorator which loads a user by the Id expected in the path.
nhoening marked this conversation as resolved.
Show resolved Hide resolved
Raises 400 if that is not possible due to wrong parameters.
Raises 404 if user is not found.
Example:

@app.route('/user/<id>')
@check_user
def get_user(user):
return user_schema.dump(user), 200

The route must specify one parameter ― id.

TODO:
- support parameters in query (see load_account)?
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
- return current_user if no ID is given?
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""

def wrapper(fn):
@wraps(fn)
@as_json
def decorated_endpoint(*args, **kwargs):

args = list(args)
if len(args) == 0:
current_app.logger.warning("Request missing id.")
return required_info_missing(["id"])

try:
id = int(args[0])
args = args[1:]
except ValueError:
current_app.logger.warning("Cannot parse ID argument from request.")
return required_info_missing(["id"], "Cannot parse ID arg as int.")

user: UserModel = UserModel.query.filter_by(id=int(id)).one_or_none()

if user is None:
raise abort(404, f"User {id} not found")

return fn(user, *args, **kwargs)

return decorated_endpoint

return wrapper
7 changes: 3 additions & 4 deletions flexmeasures/api/common/responses.py
Expand Up @@ -143,7 +143,7 @@ def invalid_role(requested_access_role: str) -> ResponseTuple:

def invalid_sender(
calling_function: Optional[Callable] = None,
allowed_role_names: Optional[List[str]] = None,
required_permissions: Optional[List[str]] = None,
) -> ResponseTuple:
"""
Signify that a sender is invalid.
Expand All @@ -153,9 +153,8 @@ def invalid_sender(
- allowed_role_names can be used if the security check involved roles.
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""
message = FORBIDDEN_MSG
if allowed_role_names:
allowed_role_names = [pluralize(role_name) for role_name in allowed_role_names]
message += f" It is reserved for {p.join(allowed_role_names)}."
if required_permissions:
message += f" It requires {p.join(required_permissions)} permission(s)."
return (
dict(result="Rejected", status="INVALID_SENDER", message=message),
FORBIDDEN_STATUS_CODE,
Expand Down
100 changes: 16 additions & 84 deletions flexmeasures/api/v2_0/implementations/users.py
@@ -1,6 +1,3 @@
from functools import wraps

from flask import current_app, abort
from marshmallow import fields
from sqlalchemy.exc import IntegrityError
from webargs.flaskparser import use_args
Expand All @@ -16,9 +13,10 @@
set_random_password,
remove_cookie_and_token_access,
)
from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE
from flexmeasures.api.common.responses import required_info_missing
from flexmeasures.auth.policy import ADMIN_ROLE
from flexmeasures.auth.decorators import permission_required_for_context
from flexmeasures.data.config import db
from flexmeasures.api.common.factories import load_user, load_account

"""
API endpoints to manage users.
Expand All @@ -32,110 +30,43 @@

@use_args(
{
"account_name": fields.Str(),
"account_id": fields.Int(),
"include_inactive": fields.Bool(missing=False),
},
location="query",
)
@load_account(param_location="query")
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
@permission_required_for_context("read")
@as_json
def get(args):
"""List users. Defaults to users in non-admin's account."""

user_is_admin = current_user.has_role(ADMIN_ROLE) or current_user.has_role(
ADMIN_READER_ROLE
)
account_name = args.get("account_name", None)
def get(account, args):
"""List users of an account."""

if account_name is None and not user_is_admin:
account_name = current_user.account.name
if (
account_name is not None
and account_name != current_user.account.name
and not user_is_admin
):
raise Forbidden(
f"User {current_user.username} cannot list users from account {account_name}."
)
users = get_users(
account_name=account_name, only_active=not args["include_inactive"]
account_name=account.name, only_active=not args["include_inactive"]
)
return users_schema.dump(users), 200


def load_user(admins_only: bool = False):
"""Decorator which loads a user by the Id expected in the path.
Raises 400 if that is not possible due to wrong parameters.
Raises 404 if user is not found.
Raises 403 if unauthorized:
Only the user themselves or admins can access a user object.
The admins_only parameter can be used if not even the user themselves
should be allowed.

@app.route('/user/<id>')
@check_user
def get_user(user):
return user_schema.dump(user), 200

The route must specify one parameter ― id.
"""

def wrapper(fn):
@wraps(fn)
@as_json
def decorated_endpoint(*args, **kwargs):

args = list(args)
if len(args) == 0:
current_app.logger.warning("Request missing id.")
return required_info_missing(["id"])
if len(args) > 1:
return (
dict(
status="UNEXPECTED_PARAMS",
message="Only expected one parameter (id).",
),
400,
)

try:
id = int(args[0])
except ValueError:
current_app.logger.warning("Cannot parse ID argument from request.")
return required_info_missing(["id"], "Cannot parse ID arg as int.")

user: UserModel = UserModel.query.filter_by(id=int(id)).one_or_none()

if user is None:
raise abort(404, f"User {id} not found")

if not current_user.has_role("admin"):
if admins_only or user != current_user:
raise Forbidden("Needs to be admin or the current user.")

args = (user,)
return fn(*args, **kwargs)

return decorated_endpoint

return wrapper


@load_user()
@permission_required_for_context("read")
@as_json
def fetch_one(user: UserModel):
"""Fetch a given user"""
return user_schema.dump(user), 200


@load_user()
@permission_required_for_context("write")
@use_args(UserSchema(partial=True))
@as_json
def patch(db_user: UserModel, user_data: dict):
"""Update a user given its identifier"""
allowed_fields = ["email", "username", "active", "timezone", "flexmeasures_roles"]
for k, v in [(k, v) for k, v in user_data.items() if k in allowed_fields]:
if current_user.id == db_user.id and k in ("active", "flexmeasures_roles"):
raise Forbidden("Users who edit themselves cannot edit sensitive fields.")
raise Forbidden(
"Users who edit themselves cannot edit security-sensitive fields."
)
setattr(db_user, k, v)
if k == "active" and v is False:
remove_cookie_and_token_access(db_user)
Expand All @@ -148,13 +79,14 @@ def patch(db_user: UserModel, user_data: dict):


@load_user()
@permission_required_for_context("write")
@as_json
def reset_password(user):
"""
Reset the user's current password, cookies and auth tokens.
Send a password reset link to the user.
"""
if current_user.id != user.id and not current_user.has_role("admin"):
if current_user.id != user.id and not current_user.has_role(ADMIN_ROLE):
raise Forbidden("Non-admins cannot reset passwords of other users.")
set_random_password(user)
remove_cookie_and_token_access(user)
Expand Down
13 changes: 4 additions & 9 deletions flexmeasures/api/v2_0/routes.py
Expand Up @@ -102,7 +102,7 @@ def get_assets():
This endpoint returns all accessible assets for a given owner.
The `owner_id` query parameter can be used to set an owner.
If no owner is set, all accessible assets are returned.
A non-admin user can only access its own assets.
A non-admin user can only access their own assets.

**Example response**

Expand Down Expand Up @@ -348,7 +348,6 @@ def delete_asset(id: int):


@flexmeasures_api_v2_0.route("/users", methods=["GET"])
@auth_token_required
def get_users():
"""API endpoint to get users.

Expand All @@ -358,7 +357,8 @@ def get_users():
By default, only active users are returned.
The `include_inactive` query parameter can be used to also fetch
inactive users.
Only admins can use this endpoint.
Accessible users are users in the same account as the current user.
Only admins can use this endpoint to fetch users from a different account (by using the `account_id` query parameter).

**Example response**

Expand All @@ -370,6 +370,7 @@ def get_users():
{
'active': True,
'email': 'test_prosumer@seita.nl',
'account_id': 13,
'flexmeasures_roles': [1, 3],
'id': 1,
'timezone': 'Europe/Amsterdam',
Expand All @@ -389,8 +390,6 @@ def get_users():


@flexmeasures_api_v2_0.route("/user/<id>", methods=["GET"])
@auth_token_required
# @account_roles_accepted(*check_access(v2_0_service_listing, "GET /user/<id>"))
def get_user(id: int):
"""API endpoint to get a user.

Expand Down Expand Up @@ -425,8 +424,6 @@ def get_user(id: int):


@flexmeasures_api_v2_0.route("/user/<id>", methods=["PATCH"])
@auth_token_required
# @account_roles_accepted(*list_access(v2_0_service_listing, "PATCH /user/<id>"))
def patch_user(id: int):
"""API endpoint to patch user data.

Expand Down Expand Up @@ -476,8 +473,6 @@ def patch_user(id: int):


@flexmeasures_api_v2_0.route("/user/<id>/password-reset", methods=["PATCH"])
@auth_token_required
# @account_roles_accepted(*check_access(v2_0_service_listing, "PATCH /user/<id>password-reset"))
def reset_user_password(id: int):
"""API endpoint to reset the user password. They'll get an email to choose a new password.

Expand Down