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 7 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
6 changes: 4 additions & 2 deletions flexmeasures/api/dev/__init__.py
@@ -1,5 +1,7 @@
from flask import Flask
from flask_security import auth_token_required, roles_accepted
from flask_security import auth_token_required

from flexmeasures.auth.decorators import account_roles_accepted


def register_at(app: Flask):
Expand All @@ -12,7 +14,7 @@ def register_at(app: Flask):

@app.route("/sensorData", methods=["POST"])
@auth_token_required
@roles_accepted("admin", "MDC", "Prosumer")
@account_roles_accepted("MDC", "Prosumer")
def post_sensor_data():
"""
Post sensor data to FlexMeasures.
Expand Down
28 changes: 15 additions & 13 deletions flexmeasures/api/dev/tests/conftest.py
@@ -1,8 +1,8 @@
from datetime import timedelta

from flask_security import SQLAlchemySessionUserDatastore
import pytest

from flexmeasures.data.models.user import Account, User
from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset
from flexmeasures.data.models.time_series import Sensor

Expand All @@ -13,8 +13,8 @@ def setup_api_test_data(db, setup_roles_users):
Set up data for API dev tests.
"""
print("Setting up data for API v2.0 tests on %s" % db.engine)
add_gas_sensor(db, setup_roles_users["Test Supplier"])
give_prosumer_the_MDC_role(db)
add_gas_sensor(db, setup_roles_users["Test User 2"])
move_user2_to_supplier()


@pytest.fixture(scope="function")
Expand All @@ -25,8 +25,8 @@ def setup_api_fresh_test_data(fresh_db, setup_roles_users_fresh_db):
print("Setting up fresh data for API dev tests on %s" % fresh_db.engine)
for sensor in Sensor.query.all():
fresh_db.delete(sensor)
add_gas_sensor(fresh_db, setup_roles_users_fresh_db["Test Supplier"])
give_prosumer_the_MDC_role(fresh_db)
add_gas_sensor(fresh_db, setup_roles_users_fresh_db["Test User 2"])
move_user2_to_supplier()


def add_gas_sensor(db, test_supplier):
Expand All @@ -38,6 +38,7 @@ def add_gas_sensor(db, test_supplier):
incineration_asset = GenericAsset(
name="incineration line",
generic_asset_type=incineration_type,
account_id=test_supplier.account_id,
)
db.session.add(incineration_asset)
db.session.flush()
Expand All @@ -51,11 +52,12 @@ def add_gas_sensor(db, test_supplier):
gas_sensor.owner = test_supplier


def give_prosumer_the_MDC_role(db):

from flexmeasures.data.models.user import User, Role

user_datastore = SQLAlchemySessionUserDatastore(db.session, User, Role)
test_prosumer = user_datastore.find_user(email="test_prosumer@seita.nl")
mdc_role = user_datastore.create_role(name="MDC", description="Meter Data Company")
user_datastore.add_role_to_user(test_prosumer, mdc_role)
def move_user2_to_supplier():
nhoening marked this conversation as resolved.
Show resolved Hide resolved
"""
move the user 2 to the supplier account
"""
supplier_account = Account.query.filter(
Account.name == "Test Supplier Account"
).one_or_none()
user2 = User.query.filter(User.email == "test_user_2@seita.nl").one_or_none()
user2.account = supplier_account
8 changes: 4 additions & 4 deletions flexmeasures/api/dev/tests/test_sensor_data.py
Expand Up @@ -6,7 +6,7 @@


@pytest.mark.parametrize("use_auth", [False, True])
def test_post_sensor_data_bad_auth(client, use_auth):
def test_post_sensor_data_bad_auth(client, setup_api_test_data, use_auth):
"""
Attempt to post sensor data with insufficient or missing auth.
"""
Expand All @@ -16,7 +16,7 @@ def test_post_sensor_data_bad_auth(client, use_auth):
# in this case, we successfully authenticate,
# but fail authorization (no admin or MDC role)
headers["Authorization"] = get_auth_token(
client, "test_supplier@seita.nl", "testtest"
client, "test_user_2@seita.nl", "testtest"
)

post_data_response = client.post(
Expand Down Expand Up @@ -51,7 +51,7 @@ def test_post_invalid_sensor_data(
post_data = make_sensor_data_request()
post_data[request_field] = new_value
# this guy is allowed to post sensorData
auth_token = get_auth_token(client, "test_prosumer@seita.nl", "testtest")
auth_token = get_auth_token(client, "test_user@seita.nl", "testtest")
response = client.post(
url_for("post_sensor_data"),
json=post_data,
Expand All @@ -63,7 +63,7 @@ def test_post_invalid_sensor_data(


def test_post_sensor_data_twice(client, setup_api_test_data):
auth_token = get_auth_token(client, "test_prosumer@seita.nl", "testtest")
auth_token = get_auth_token(client, "test_user@seita.nl", "testtest")
post_data = make_sensor_data_request()
response = client.post(
url_for("post_sensor_data"),
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/dev/tests/test_sensor_data_fresh_db.py
Expand Up @@ -24,7 +24,7 @@ def test_post_sensor_data(
print(f"BELIEFS BEFORE: {beliefs_before}")
assert len(beliefs_before) == 0

auth_token = get_auth_token(client, "test_prosumer@seita.nl", "testtest")
auth_token = get_auth_token(client, "test_user@seita.nl", "testtest")
response = client.post(
url_for("post_sensor_data"),
json=post_data,
Expand Down
4 changes: 2 additions & 2 deletions flexmeasures/api/tests/conftest.py
Expand Up @@ -8,7 +8,7 @@


@pytest.fixture(scope="module", autouse=True)
def setup_api_test_data(db, setup_account, setup_roles_users):
def setup_api_test_data(db, setup_accounts, setup_roles_users):
"""
Adding the task-runner
"""
Expand All @@ -31,7 +31,7 @@ def setup_api_test_data(db, setup_account, setup_roles_users):
username="test user",
email="task_runner@seita.nl",
password=hash_password("testtest"),
account_id=setup_account.id,
account_id=setup_accounts["Prosumer"].id,
)
user_datastore.add_role_to_user(test_task_runner, test_task_runner_role)

Expand Down
7 changes: 3 additions & 4 deletions flexmeasures/api/tests/test_task_runs.py
Expand Up @@ -5,23 +5,22 @@
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,
UNAUTH_STATUS_CODE,
)


def test_api_task_run_post_unauthorized_wrong_role(client):
url = url_for("flexmeasures_api_ops.post_task_run")
auth_token = get_auth_token(client, "test_prosumer@seita.nl", "testtest")
auth_token = get_auth_token(client, "test_user@seita.nl", "testtest")
post_req_params = dict(
query_string={"name": "my-task"}, headers={"Authorization": auth_token}
)
task_run = client.post(url, **post_req_params)
assert task_run.status_code == FORBIDDEN_STATUS_CODE
assert bytes(FORBIDDEN_ERROR_CLASS, encoding="utf") in task_run.data
assert b"cannot be authorized" in task_run.data
# While we are on it, test if the unauth handler correctly returns json if we set the content-type
post_req_params.update(
headers={"Authorization": auth_token, "Content-Type": "application/json"}
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/api/tests/utils.py
Expand Up @@ -36,7 +36,7 @@ class UserContext(object):
While the context is alive, you can collect any useful information, like
the user's assets:

with UserContext("test_prosumer@seita.nl") as prosumer:
with UserContext("test_user@seita.nl") as prosumer:
assets = prosumer.assets
"""

Expand Down
6 changes: 3 additions & 3 deletions flexmeasures/api/v1/routes.py
@@ -1,8 +1,8 @@
from flask_security import auth_token_required

from flexmeasures.auth.decorators import account_roles_accepted
from flexmeasures.api.common.utils.api_utils import list_access
from flexmeasures.api.common.utils.decorators import as_response_type
from flexmeasures.api.common.utils.validators import usef_roles_accepted
from flexmeasures.api.v1 import (
flexmeasures_api as flexmeasures_api_v1,
implementations as v1_implementations,
Expand Down Expand Up @@ -30,7 +30,7 @@
@flexmeasures_api_v1.route("/getMeterData", methods=["GET", "POST"])
@as_response_type("GetMeterDataResponse")
@auth_token_required
@usef_roles_accepted(*list_access(v1_service_listing, "getMeterData"))
@account_roles_accepted(*list_access(v1_service_listing, "getMeterData"))
def get_meter_data():
"""API endpoint to get meter data.

Expand Down Expand Up @@ -95,7 +95,7 @@ def get_meter_data():
@flexmeasures_api_v1.route("/postMeterData", methods=["POST"])
@as_response_type("PostMeterDataResponse")
@auth_token_required
@usef_roles_accepted(*list_access(v1_service_listing, "postMeterData"))
@account_roles_accepted(*list_access(v1_service_listing, "postMeterData"))
def post_meter_data():
"""API endpoint to post meter data.

Expand Down