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

Auth policy #210

Merged
merged 24 commits into from Oct 31, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
215fa00
make flexmeasures.auth its own top-level package
nhoening Oct 6, 2021
2d7946e
fix issue in naturaltime test due to humanize lib adding a year if da…
nhoening Oct 12, 2021
c27eb21
central auth policy with decorators and error handling. Added account…
nhoening Oct 12, 2021
8e02ec0
apply new decorators to endoints, switch some user-role-dependent cod…
nhoening Oct 12, 2021
eea57a4
properly comment out a Jinja comment which should not be rendered
nhoening Oct 12, 2021
cd30244
make tests work in changed auth world.
nhoening Oct 12, 2021
f832c60
Merge branch 'main' into auth-policy
nhoening Oct 12, 2021
c0e0ac4
changelog entry
nhoening Oct 14, 2021
abf5a98
documentation
nhoening Oct 14, 2021
ade6647
Merge branch 'auth-policy' of github.com:SeitaBV/flexmeasures into au…
nhoening Oct 14, 2021
ea595c9
add missing dev/auth chapter
nhoening Oct 14, 2021
2cd93cd
rename services chapter in docs to inbuilt-smart-functionality
nhoening Oct 14, 2021
e44e14d
remove unused code
nhoening Oct 14, 2021
78de98e
use the ADMIN_ROLE name throughout
nhoening Oct 14, 2021
3f5f3e8
implement smaller review comments
nhoening Oct 29, 2021
9755afe
give test users better names and email adresses
nhoening Oct 29, 2021
4bf020d
More straightforward use of test users: add a true admin user in main…
nhoening Oct 29, 2021
8a61d34
Merge branch 'main' into auth-policy
nhoening Oct 29, 2021
e11bf10
create new admin user last, so somw tests which assume users ids 1 or…
nhoening Oct 29, 2021
719ffb9
Merge branch 'auth-policy' of github.com:SeitaBV/flexmeasures into au…
nhoening Oct 29, 2021
67e1ef6
move changelog entry to v0.8.0
nhoening Oct 29, 2021
038fac8
remove unused imports
nhoening Oct 29, 2021
c722ef7
implement review comments about documentation and docstrings
nhoening Oct 30, 2021
78620c5
one more typo
nhoening Oct 31, 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
2 changes: 2 additions & 0 deletions flexmeasures/api/__init__.py
Expand Up @@ -8,6 +8,7 @@
from flexmeasures.api.common.utils.args_parsing import (
validation_error_handler,
)
from flexmeasures.api.common.responses import invalid_sender
from flexmeasures.data.schemas.utils import FMValidationError

# The api blueprint. It is registered with the Flask app (see app.py)
Expand Down Expand Up @@ -83,6 +84,7 @@ def register_at(app: Flask):

# handle API specific errors
app.register_error_handler(FMValidationError, validation_error_handler)
app.unauthorized_handler_api = invalid_sender

app.register_blueprint(
flexmeasures_api, url_prefix="/api"
Expand Down
39 changes: 18 additions & 21 deletions flexmeasures/api/common/responses.py
@@ -1,7 +1,9 @@
from typing import Optional, Tuple, Union, Sequence
from typing import Callable, List, Optional, Tuple, Union, Sequence
import inflect
from functools import wraps

from flexmeasures.auth.error_handling import FORBIDDEN_MSG, FORBIDDEN_STATUS_CODE

p = inflect.engine()


Expand Down Expand Up @@ -140,28 +142,23 @@ def invalid_role(requested_access_role: str) -> ResponseTuple:


def invalid_sender(
user_role_names: Union[str, Sequence[str]], *allowed_role_names: str
calling_function: Optional[Callable] = None,
allowed_role_names: Optional[List[str]] = None,
) -> ResponseTuple:
if isinstance(user_role_names, str):
user_role_names = [user_role_names]
if not user_role_names:
user_roles_str = "have no role"
else:
user_role_names = [p.a(role_name) for role_name in user_role_names]
user_roles_str = "are %s" % p.join(user_role_names)
allowed_role_names = tuple(
[pluralize(role_name) for role_name in allowed_role_names]
)
allowed_role_names = p.join(allowed_role_names)
"""
Signify that a sender is invalid.
We use this as a stand-in for flask-security auth handlers,
thus the arguments fit it.
- calling_function is usually an auth decorator like roles_required.
- allowed_role_names can be used if the security check involved roles.
"""
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)}."
return (
dict(
result="Rejected",
status="INVALID_SENDER",
message="You don't have the right role to access this service. "
"You %s while this service is reserved for %s."
% (user_roles_str, allowed_role_names),
),
403,
dict(result="Rejected", status="INVALID_SENDER", message=message),
FORBIDDEN_STATUS_CODE,
)


Expand Down
49 changes: 2 additions & 47 deletions flexmeasures/api/common/utils/validators.py
Expand Up @@ -10,7 +10,6 @@
from pandas.tseries.frequencies import to_offset
from flask import request, current_app
from flask_json import as_json
from flask_principal import Permission, RoleNeed
from flask_security import current_user
import marshmallow

Expand All @@ -27,7 +26,6 @@
unapplicable_resolution,
invalid_resolution_str,
conflicting_resolutions,
invalid_sender,
invalid_source,
invalid_timezone,
invalid_unit,
Expand Down Expand Up @@ -76,8 +74,8 @@ def validate_user_sources(sources: Union[int, str, List[Union[int, str]]]) -> Li
except TypeError:
current_app.logger.warning("Could not retrieve data source %s" % source)
pass
else: # Parse as role name
user_ids = [user.id for user in get_users(source)]
else: # Parse as account role name
nhoening marked this conversation as resolved.
Show resolved Hide resolved
user_ids = [user.id for user in get_users(account_role_name=source)]
user_source_ids.extend(
[
params[0]
Expand Down Expand Up @@ -912,46 +910,3 @@ def decorated_service(*args, **kwargs):
return decorated_service

return wrapper


def usef_roles_accepted(*usef_roles):
"""Decorator which specifies that a user must have at least one of the
specified USEF roles (or must be an admin). Example:

@app.route('/postMeterData')
@roles_accepted('Prosumer', 'MDC')
def post_meter_data():
return 'Meter data posted'

The current user must have either the `Prosumer` role or `MDC` role in
order to use the service.
And finally, users with the anonymous user role are never accepted.

:param usef_roles: The possible roles.
"""

def wrapper(fn):
@wraps(fn)
@as_json
def decorated_service(*args, **kwargs):
perm = Permission(*[RoleNeed(role) for role in usef_roles])
if current_user.has_role(
"anonymous"
): # TODO: this role needs to go, we should not mix permissive and restrictive roles
current_app.logger.warning(
"Anonymous user is not accepted for this service"
)
return invalid_sender("anonymous user", "non-anonymous user")
elif perm.can() or current_user.has_role("admin"):
return fn(*args, **kwargs)
else:
current_app.logger.warning(
"User does not have necessary authorization for this service"
)
return invalid_sender(
[role.name for role in current_user.roles], *usef_roles
)

return decorated_service

return wrapper
1 change: 1 addition & 0 deletions flexmeasures/auth/__init__.py
Expand Up @@ -6,6 +6,7 @@
from flexmeasures.data.config import db
from flexmeasures.data.models.user import User, Role, remember_login


"""
Configure authentication and authorization.
"""
Expand Down
104 changes: 104 additions & 0 deletions flexmeasures/auth/decorators.py
@@ -0,0 +1,104 @@
from functools import wraps
from flask import current_app
from flask_json import as_json
from flask_security import (
current_user,
roles_accepted as roles_accepted_fs,
roles_required as roles_required_fs,
)
from werkzeug.local import LocalProxy

from flexmeasures.auth.policy import ADMIN_ROLE


"""
For docs:
in FlexMeasures, we recommend to make use of the following decorators:

- roles_accepted
- roles_required
- account_roles_accepted
- account_roles_required

However, these do not work on admin-reader.

Better to use the decorators we will create in a PR soon.
"""
_security = LocalProxy(lambda: current_app.extensions["security"])


def roles_accepted(*roles):
""" As in Flask-Security, but also accept admin"""
if "admin" not in roles:
roles = roles + (ADMIN_ROLE,)
return roles_accepted_fs(roles)


def roles_required(*roles):
""" As in Flask-Security, but wave through if user is admin"""
if current_user and current_user.has_role(ADMIN_ROLE):
roles = []
return roles_required_fs(*roles)


def account_roles_accepted(*account_roles):
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""Decorator which specifies that a user's account must have at least one of the
specified roles (or must be an admin). Example:

@app.route('/postMeterData')
@account_roles_accepted('Prosumer', 'MDC')
def post_meter_data():
return 'Meter data posted'

The current user's account must have either the `Prosumer` role or `MDC` role in
order to use the service.

:param account_roles: The possible roles.
"""

def wrapper(fn):
@wraps(fn)
@as_json
def decorated_service(*args, **kwargs):
for role in account_roles:
if (
current_user
and current_user.account.has_role(role)
or current_user.has_role(ADMIN_ROLE)
):
return fn(*args, **kwargs)
return _security._unauthz_handler(account_roles_accepted, account_roles)

return decorated_service

return wrapper


def account_roles_required(*account_roles):
"""Decorator which specifies that a user's account must have all the specified roles.
Example::

@app.route('/dashboard')
@account_roles_required('Prosumer', 'App-subscriber')
def dashboard():
return 'Dashboard'

The current user's account must have both the `Prosumer` role and
`App-subscriber` role in order to view the page.

:param roles: The required roles.
"""

def wrapper(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
for role in account_roles:
if not current_user or not current_user.account.has_role(role):
return _security._unauthz_handler(
account_roles_required, account_roles
)
return fn(*args, **kwargs)

return decorated_view

return wrapper
36 changes: 20 additions & 16 deletions flexmeasures/auth/error_handling.py
Expand Up @@ -32,13 +32,14 @@
# Preferably to be used when the user is logged in but is not authorized for the resource.
# Advice: a not logged-in user should preferably see a 404 NotFound.
FORBIDDEN_STATUS_CODE = 403
FORBIDDEN_ERROR_CLASS = "Forbidden"
FORBIDDEN_ERROR_STATUS = "FORBIDDEN"
FORBIDDEN_ERROR_CLASS = "InvalidSender"
FORBIDDEN_ERROR_STATUS = "INVALID_SENDER"
FORBIDDEN_MSG = "You cannot be authorized for this content or functionality."


def unauthorized_handler_e(e):
"""Swallow error. Useful for classical Flask error handler registration."""
current_app.logger.error(f"Authorization error: {e}")
return unauthorized_handler(None, [])


Expand All @@ -47,23 +48,24 @@ def unauthorized_handler(func: Optional[Callable], params: list):
Handler for authorization problems.
:param func: the Flask-Security-Too decorator, if relevant, and params are its parameters.

We support json if the request supports it.
The ui package can also define how it wants to render HTML errors.
We respond with json if the request doesn't say otherwise.
Also, other FlexMeasures packages can define that they want to wrap JSON responses
render HTML error pages (for non-JSON requests) in custom ways.
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""
if func is not None:
func(*params)
if request.is_json:
if request.is_json or request.content_type is None:
if hasattr(current_app, "unauthorized_handler_api"):
return current_app.unauthorized_handler_api(func, params)
response = jsonify(dict(message=FORBIDDEN_MSG, status=FORBIDDEN_ERROR_STATUS))
response.status_code = FORBIDDEN_STATUS_CODE
return response
elif hasattr(current_app, "unauthorized_handler_html"):
if hasattr(current_app, "unauthorized_handler_html"):
return current_app.unauthorized_handler_html()
else:
return "%s:%s" % (FORBIDDEN_ERROR_CLASS, FORBIDDEN_MSG), FORBIDDEN_STATUS_CODE
return "%s:%s" % (FORBIDDEN_ERROR_CLASS, FORBIDDEN_MSG), FORBIDDEN_STATUS_CODE


def unauthenticated_handler_e(e):
"""Swallow error. Useful for classical Flask error handler registration."""
current_app.logger.error(f"Authentication error: {e}")
return unauthenticated_handler([])


Expand All @@ -72,16 +74,18 @@ def unauthenticated_handler(mechanisms: list, headers: Optional[dict] = None):
Handler for authentication problems.
:param mechanisms: a list of which authentication mechanisms were tried.
:param headers: a dict of headers to return.
We support json if the request supports it.
The ui package can also define how it wants to render HTML errors.
We respond with json if the request doesn't say otherwise.
Also, other FlexMeasures packages can define that they want to wrap JSON responses
render HTML error pages (for non-JSON requests) in custom ways.
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""
if request.is_json:
if request.is_json or request.content_type is None:
if hasattr(current_app, "unauthenticated_handler_api"):
return current_app.unauthenticated_handler_api(None, [])
response = jsonify(dict(message=UNAUTH_MSG, status=UNAUTH_ERROR_STATUS))
response.status_code = UNAUTH_STATUS_CODE
if headers is not None:
response.headers.update(headers)
return response
elif hasattr(current_app, "unauthenticated_handler_html"):
if hasattr(current_app, "unauthenticated_handler_html"):
return current_app.unauthenticated_handler_html()
else:
return "%s:%s" % (UNAUTH_ERROR_CLASS, UNAUTH_MSG), UNAUTH_STATUS_CODE
return "%s:%s" % (UNAUTH_ERROR_CLASS, UNAUTH_MSG), UNAUTH_STATUS_CODE
4 changes: 4 additions & 0 deletions flexmeasures/auth/policy.py
@@ -0,0 +1,4 @@
PERMISSIONS = ["create", "read", "write", "delete"] # TODO: use in access control lists
nhoening marked this conversation as resolved.
Show resolved Hide resolved

ADMIN_ROLE = "admin"
ADMIN_READER_ROLE = "admin-reader"
40 changes: 40 additions & 0 deletions flexmeasures/auth/utils.py
@@ -0,0 +1,40 @@
from typing import Union

from flask import abort

from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.models.assets import Asset
from flexmeasures.data.models.user import User
from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE


def check_user_access(user: User, sensor: Union[Sensor, Asset], permission: str):
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""
Only allow access if the user is on the same account as the asset or if they are admins.

Raises auth error if they are not.

We look up the account of the owner to check. Editing public
GenericAssets is thus only possible for admins.

In the future, Assets will become Sensors, so then we'll drop that Asset check.
For now: each Asset has a Generic Asset and a Sensor with matching ID. The GenericAsset.account_id
should match that of asset.owner and should not change.
"""
if user.has_role(ADMIN_ROLE):
return
if permission == "read" and user.has_role(ADMIN_READER_ROLE):
return
access_valid = False
if isinstance(sensor, Asset):
if user and user.account == sensor.owner.account:
access_valid = True
else:
if user and user.account == sensor.generic_asset.owner:
access_valid = True

if not access_valid:
raise abort(
403,
f"User {user.username} is not allowed to access Asset {sensor.name}, as their account differs.",
)