From fa7ffd9d1594a5bc9dba4fc65af0a4100988341b Mon Sep 17 00:00:00 2001 From: Fabian Mastenbroek Date: Fri, 2 Jul 2021 16:47:40 +0200 Subject: api: Restrict API scopes This change adds support for restricting API scopes in the OpenDC API server. This is necessary to make a distinction between runners and regular users. --- opendc-web/opendc-web-api/conftest.py | 15 +++++++-- opendc-web/opendc-web-api/opendc/api/jobs.py | 6 ++-- opendc-web/opendc-web-api/opendc/api/portfolios.py | 7 +++-- opendc-web/opendc-web-api/opendc/api/scenarios.py | 8 +++-- opendc-web/opendc-web-api/opendc/api/topologies.py | 8 +++-- opendc-web/opendc-web-api/opendc/exts.py | 36 +++++++++++++++++++++- opendc-web/opendc-web-api/static/schema.yml | 1 + 7 files changed, 69 insertions(+), 12 deletions(-) diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py index 430262f1..958a5894 100644 --- a/opendc-web/opendc-web-api/conftest.py +++ b/opendc-web/opendc-web-api/conftest.py @@ -8,7 +8,7 @@ from flask import _request_ctx_stack, g from opendc.database import Database -def decorator(f): +def requires_auth_mock(f): @wraps(f) def decorated_function(*args, **kwargs): _request_ctx_stack.top.current_user = {'sub': 'test'} @@ -16,13 +16,24 @@ def decorator(f): return decorated_function +def requires_scope_mock(required_scope): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + return f(*args, **kwargs) + return decorated_function + return decorator + + @pytest.fixture def client(): """Returns a Flask API client to interact with.""" # Disable authorization for test API endpoints from opendc import exts - exts.requires_auth = decorator + exts.requires_auth = requires_auth_mock + exts.requires_scope = requires_scope_mock + exts.has_scope = lambda x: False from app import create_app diff --git a/opendc-web/opendc-web-api/opendc/api/jobs.py b/opendc-web/opendc-web-api/opendc/api/jobs.py index 5feaea16..6fb0522b 100644 --- a/opendc-web/opendc-web-api/opendc/api/jobs.py +++ b/opendc-web/opendc-web-api/opendc/api/jobs.py @@ -22,7 +22,7 @@ from flask_restful import Resource from marshmallow import fields, Schema, validate from werkzeug.exceptions import BadRequest, Conflict -from opendc.exts import requires_auth +from opendc.exts import requires_auth, requires_scope from opendc.models.scenario import Scenario @@ -54,7 +54,7 @@ class JobList(Resource): """ Resource representing the list of available jobs. """ - method_decorators = [requires_auth] + method_decorators = [requires_auth, requires_scope('runner')] def get(self): """Get all available jobs.""" @@ -67,7 +67,7 @@ class Job(Resource): """ Resource representing a single job. """ - method_decorators = [requires_auth] + method_decorators = [requires_auth, requires_scope('runner')] def get(self, job_id): """Get the details of a single job.""" diff --git a/opendc-web/opendc-web-api/opendc/api/portfolios.py b/opendc-web/opendc-web-api/opendc/api/portfolios.py index 84ec466c..eea82289 100644 --- a/opendc-web/opendc-web-api/opendc/api/portfolios.py +++ b/opendc-web/opendc-web-api/opendc/api/portfolios.py @@ -22,7 +22,7 @@ from flask import request from flask_restful import Resource from marshmallow import Schema, fields -from opendc.exts import requires_auth, current_user +from opendc.exts import requires_auth, current_user, has_scope from opendc.models.portfolio import Portfolio as PortfolioModel, PortfolioSchema from opendc.models.project import Project from opendc.models.scenario import ScenarioSchema, Scenario @@ -42,7 +42,10 @@ class Portfolio(Resource): portfolio = PortfolioModel.from_id(portfolio_id) portfolio.check_exists() - portfolio.check_user_access(current_user['sub'], False) + + # Users with scope runner can access all portfolios + if not has_scope('runner'): + portfolio.check_user_access(current_user['sub'], False) data = PortfolioSchema().dump(portfolio.obj) return {'data': data} diff --git a/opendc-web/opendc-web-api/opendc/api/scenarios.py b/opendc-web/opendc-web-api/opendc/api/scenarios.py index 234bdec1..eacb0b49 100644 --- a/opendc-web/opendc-web-api/opendc/api/scenarios.py +++ b/opendc-web/opendc-web-api/opendc/api/scenarios.py @@ -24,7 +24,7 @@ from marshmallow import Schema, fields from opendc.models.scenario import Scenario as ScenarioModel, ScenarioSchema from opendc.models.portfolio import Portfolio -from opendc.exts import current_user, requires_auth +from opendc.exts import current_user, requires_auth, has_scope class Scenario(Resource): @@ -37,7 +37,11 @@ class Scenario(Resource): """Get scenario by identifier.""" scenario = ScenarioModel.from_id(scenario_id) scenario.check_exists() - scenario.check_user_access(current_user['sub'], False) + + # Users with scope runner can access all scenarios + if not has_scope('runner'): + scenario.check_user_access(current_user['sub'], False) + data = ScenarioSchema().dump(scenario.obj) return {'data': data} diff --git a/opendc-web/opendc-web-api/opendc/api/topologies.py b/opendc-web/opendc-web-api/opendc/api/topologies.py index a2d3f41a..c0b2e7ee 100644 --- a/opendc-web/opendc-web-api/opendc/api/topologies.py +++ b/opendc-web/opendc-web-api/opendc/api/topologies.py @@ -26,7 +26,7 @@ from marshmallow import Schema, fields from opendc.models.project import Project from opendc.models.topology import Topology as TopologyModel, TopologySchema -from opendc.exts import current_user, requires_auth +from opendc.exts import current_user, requires_auth, has_scope class Topology(Resource): @@ -41,7 +41,11 @@ class Topology(Resource): """ topology = TopologyModel.from_id(topology_id) topology.check_exists() - topology.check_user_access(current_user['sub'], False) + + # Users with scope runner can access all topologies + if not has_scope('runner'): + topology.check_user_access(current_user['sub'], False) + data = TopologySchema().dump(topology.obj) return {'data': data} diff --git a/opendc-web/opendc-web-api/opendc/exts.py b/opendc-web/opendc-web-api/opendc/exts.py index d24f7197..17dacd5e 100644 --- a/opendc-web/opendc-web-api/opendc/exts.py +++ b/opendc-web/opendc-web-api/opendc/exts.py @@ -2,10 +2,11 @@ import os from functools import wraps from flask import g, _request_ctx_stack +from jose import jwt from werkzeug.local import LocalProxy from opendc.database import Database -from opendc.auth import AuthContext, AsymmetricJwtAlgorithm, get_token +from opendc.auth import AuthContext, AsymmetricJwtAlgorithm, get_token, AuthError def get_db(): @@ -56,3 +57,36 @@ def requires_auth(f): current_user = LocalProxy(lambda: getattr(_request_ctx_stack.top, 'current_user', None)) + + +def has_scope(required_scope): + """Determines if the required scope is present in the Access Token + Args: + required_scope (str): The scope required to access the resource + """ + token = get_token() + unverified_claims = jwt.get_unverified_claims(token) + if unverified_claims.get("scope"): + token_scopes = unverified_claims["scope"].split() + for token_scope in token_scopes: + if token_scope == required_scope: + return True + return False + + +def requires_scope(required_scope): + """Determines if the required scope is present in the Access Token + Args: + required_scope (str): The scope required to access the resource + """ + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if not has_scope(required_scope): + raise AuthError({ + "code": "Unauthorized", + "description": "You don't have access to this resource" + }, 403) + return f(*args, **kwargs) + return decorated + return decorator diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml index 5a8c6825..6a07ae52 100644 --- a/opendc-web/opendc-web-api/static/schema.yml +++ b/opendc-web/opendc-web-api/static/schema.yml @@ -1180,6 +1180,7 @@ components: tokenUrl: https://opendc.eu.auth0.com/oauth/token scopes: openid: Grants access to user_id + runner: Grants access to runner jobs schemas: Unauthorized: type: object -- cgit v1.2.3