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 3 commits
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
2 changes: 1 addition & 1 deletion flexmeasures/api/common/implementations.py
Expand Up @@ -8,7 +8,7 @@

from flexmeasures.data.config import db
from flexmeasures.data.models.task_runs import LatestTaskRun
from flexmeasures.data.auth_setup import UNAUTH_STATUS_CODE, FORBIDDEN_STATUS_CODE
from flexmeasures.auth.error_handling import UNAUTH_STATUS_CODE, FORBIDDEN_STATUS_CODE


@as_json
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
4 changes: 2 additions & 2 deletions flexmeasures/api/common/schemas/sensor_data.py
Expand Up @@ -16,7 +16,7 @@


class SingleValueField(fields.Float):
"""Field that both deserializes and serializes a single value to a list of floats (length 1)."""
"""Field that both de-serializes and serializes a single value to a list of floats (length 1)."""

def _deserialize(self, value, attr, obj, **kwargs) -> List[float]:
return [self._validated(value)]
Expand All @@ -42,7 +42,7 @@ def select_schema_to_ensure_list_of_floats(
"values": 3.7
}

Either will be deserialized to [3.7].
Either will be de-serialized to [3.7].

Note that serialization always results in a list of floats.
This ensures that we are not requiring the same flexibility from users who are retrieving data.
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
2 changes: 1 addition & 1 deletion flexmeasures/api/tests/test_task_runs.py
Expand Up @@ -5,7 +5,7 @@
import isodate

from flexmeasures.api.tests.utils import get_auth_token, get_task_run, post_task_run
from flexmeasures.data.auth_setup import (
from flexmeasures.auth.error_handling import (
FORBIDDEN_ERROR_STATUS,
FORBIDDEN_STATUS_CODE,
FORBIDDEN_ERROR_CLASS,
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/v1/tests/test_api_v1.py
Expand Up @@ -20,7 +20,7 @@
message_for_post_meter_data,
verify_power_in_db,
)
from flexmeasures.data.auth_setup import UNAUTH_ERROR_STATUS
from flexmeasures.auth.error_handling import UNAUTH_ERROR_STATUS
from flexmeasures.data.models.assets import Asset


Expand Down
3 changes: 1 addition & 2 deletions flexmeasures/api/v1_1/tests/test_api_v1_1.py
Expand Up @@ -21,8 +21,7 @@
verify_prices_in_db,
get_forecasting_jobs,
)
from flexmeasures.data.auth_setup import UNAUTH_ERROR_STATUS

from flexmeasures.auth.error_handling import UNAUTH_ERROR_STATUS
from flexmeasures.data.models.data_sources import DataSource
from flexmeasures.data.models.user import User
from flexmeasures.data.models.markets import Market
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/v2_0/implementations/assets.py
Expand Up @@ -12,7 +12,7 @@
from flexmeasures.data.models.user import User
from flexmeasures.data.models.assets import Asset as AssetModel
from flexmeasures.data.schemas.assets import AssetSchema
from flexmeasures.data.auth_setup import unauthorized_handler
from flexmeasures.auth.error_handling import unauthorized_handler
from flexmeasures.data.config import db
from flexmeasures.api.common.responses import required_info_missing

Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/v2_0/implementations/users.py
Expand Up @@ -15,7 +15,7 @@
set_random_password,
remove_cookie_and_token_access,
)
from flexmeasures.data.auth_setup import unauthorized_handler
from flexmeasures.auth.error_handling import unauthorized_handler
from flexmeasures.api.common.responses import required_info_missing
from flexmeasures.data.config import db

Expand Down
6 changes: 6 additions & 0 deletions flexmeasures/app.py
Expand Up @@ -97,6 +97,12 @@ def create(env: Optional[str] = None, path_to_config: Optional[str] = None) -> F

register_db_at(app)

# add auth policy

from flexmeasures.auth import register_at as register_auth_at

register_auth_at(app)

# Register the API

from flexmeasures.api import register_at as register_api_at
Expand Down
41 changes: 41 additions & 0 deletions flexmeasures/auth/__init__.py
@@ -0,0 +1,41 @@
from flask import Flask
from flask_security import Security, SQLAlchemySessionUserDatastore
from flask_login import user_logged_in
from werkzeug.exceptions import Forbidden, Unauthorized

from flexmeasures.data.config import db
from flexmeasures.data.models.user import User, Role, remember_login


"""
Configure authentication and authorization.
"""


def register_at(app: Flask):

from flexmeasures.auth.error_handling import (
unauthenticated_handler,
unauthenticated_handler_e,
) # noqa: F401
from flexmeasures.auth.error_handling import (
unauthorized_handler,
unauthorized_handler_e,
) # noqa: F401

# Setup Flask-Security-Too for user authentication & authorization
user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role)
app.security = Security(app, user_datastore)

# Register custom auth problem handlers.
# Note how we are switching authorization and authentication - read more about this in error_handling.py!
# Flask-Security-Too seems to handle it the intended way:
# https://flask-security-too.readthedocs.io/en/stable/api.html#flask_security.Security.unauthn_handler
# is defaulting to 401.
app.security.unauthn_handler(unauthenticated_handler)
app.register_error_handler(Unauthorized, unauthenticated_handler_e)
app.security.unauthz_handler(unauthorized_handler)
app.register_error_handler(Forbidden, unauthorized_handler_e)

# add our custom handler for a user login event
user_logged_in.connect(remember_login)
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