From d4fec022c5e5ad38e7b5a488cb28e320ea1d6416 Mon Sep 17 00:00:00 2001 From: Fabian Mastenbroek Date: Fri, 2 Jul 2021 13:16:45 +0200 Subject: api: Fix OpenAPI topology schema This change addresses some issues in the OpenAPI schema for the datacenter topology. --- opendc-web/opendc-web-api/static/schema.yml | 32 ++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) (limited to 'opendc-web/opendc-web-api') diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml index 99e88095..6e0bddfd 100644 --- a/opendc-web/opendc-web-api/static/schema.yml +++ b/opendc-web/opendc-web-api/static/schema.yml @@ -1153,6 +1153,10 @@ components: object: type: object properties: + _id: + type: string + name: + type: string capacity: type: integer powerCapacityW: @@ -1162,52 +1166,70 @@ components: items: type: object properties: + _id: + type: string position: type: integer - cpuItems: + cpus: type: array items: type: object properties: + _id: + type: string name: type: string clockRateMhz: type: integer numberOfCores: type: integer - gpuItems: + energyConsumptionW: + type: integer + gpus: type: array items: type: object properties: + _id: + type: string name: type: string clockRateMhz: type: integer numberOfCores: type: integer - memoryItems: + energyConsumptionW: + type: integer + memories: type: array items: type: object properties: + _id: + type: string name: type: string speedMbPerS: type: integer sizeMb: type: integer - storageItems: + energyConsumptionW: + type: integer + storages: type: array items: - type: integer + type: object properties: + _id: + type: string name: type: string speedMbPerS: type: integer sizeMb: type: integer + energyConsumptionW: + type: integer Portfolio: type: object properties: -- cgit v1.2.3 From e2ec16a1a40f3ffc437378b4e22fda64f86fe284 Mon Sep 17 00:00:00 2001 From: Fabian Mastenbroek Date: Fri, 2 Jul 2021 13:26:09 +0200 Subject: api: Pass audience during Swagger UI authentication This change updates the Swagger UI configuration to pass the Auth0 audience to the authorization URL in order to obtain a valid JWT token. --- opendc-web/opendc-web-api/app.py | 1 + 1 file changed, 1 insertion(+) (limited to 'opendc-web/opendc-web-api') diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py index 5916046b..96a1ca7a 100755 --- a/opendc-web/opendc-web-api/app.py +++ b/opendc-web/opendc-web-api/app.py @@ -89,6 +89,7 @@ def setup_swagger(app): }, oauth_config={ 'clientId': os.environ.get("AUTH0_DOCS_CLIENT_ID", ""), + 'additionalQueryStringParams': {'audience': os.environ.get("AUTH0_AUDIENCE", "https://api.opendc.org/v2/")}, } ) app.register_blueprint(swaggerui_blueprint) -- cgit v1.2.3 From 45b73e4683cce35de79117c5b4a6919556d9644f Mon Sep 17 00:00:00 2001 From: Fabian Mastenbroek Date: Fri, 2 Jul 2021 14:26:23 +0200 Subject: api: Add stricter validation of input/output data This change adds stricter validation of data that enters and leaves the database. As a result, we clearly separate the database model from the data model that the REST API exports. --- opendc-web/opendc-web-api/opendc/api/portfolios.py | 9 +++---- opendc-web/opendc-web-api/opendc/api/prefabs.py | 19 ++++++++------- opendc-web/opendc-web-api/opendc/api/projects.py | 28 ++++++++++++---------- opendc-web/opendc-web-api/opendc/api/scenarios.py | 7 +++--- opendc-web/opendc-web-api/opendc/api/topologies.py | 10 ++++---- opendc-web/opendc-web-api/opendc/api/traces.py | 6 ++--- opendc-web/opendc-web-api/opendc/auth.py | 3 +-- opendc-web/opendc-web-api/opendc/database.py | 11 --------- opendc-web/opendc-web-api/opendc/exts.py | 4 +--- .../opendc-web-api/opendc/models/portfolio.py | 2 +- opendc-web/opendc-web-api/opendc/models/prefab.py | 3 ++- opendc-web/opendc-web-api/opendc/models/project.py | 13 ++++++++-- .../opendc-web-api/opendc/models/scenario.py | 24 ++++++++++++++++++- .../opendc-web-api/opendc/models/topology.py | 4 ++-- opendc-web/opendc-web-api/opendc/models/trace.py | 9 +++++++ 15 files changed, 94 insertions(+), 58 deletions(-) (limited to 'opendc-web/opendc-web-api') diff --git a/opendc-web/opendc-web-api/opendc/api/portfolios.py b/opendc-web/opendc-web-api/opendc/api/portfolios.py index b07e9da5..84ec466c 100644 --- a/opendc-web/opendc-web-api/opendc/api/portfolios.py +++ b/opendc-web/opendc-web-api/opendc/api/portfolios.py @@ -44,7 +44,7 @@ class Portfolio(Resource): portfolio.check_exists() portfolio.check_user_access(current_user['sub'], False) - data = portfolio.obj + data = PortfolioSchema().dump(portfolio.obj) return {'data': data} def put(self, portfolio_id): @@ -63,7 +63,7 @@ class Portfolio(Resource): portfolio.set_property('targets.repeatsPerScenario', result['portfolio']['targets']['repeatsPerScenario']) portfolio.update() - data = portfolio.obj + data = PortfolioSchema().dump(portfolio.obj) return {'data': data} def delete(self, portfolio_id): @@ -84,7 +84,8 @@ class Portfolio(Resource): project.update() old_object = portfolio.delete() - return {'data': old_object} + data = PortfolioSchema().dump(old_object) + return {'data': data} class PutSchema(Schema): """ @@ -125,7 +126,7 @@ class PortfolioScenarios(Resource): portfolio.obj['scenarioIds'].append(scenario.get_id()) portfolio.update() - data = scenario.obj + data = ScenarioSchema().dump(scenario.obj) return {'data': data} class PostSchema(Schema): diff --git a/opendc-web/opendc-web-api/opendc/api/prefabs.py b/opendc-web/opendc-web-api/opendc/api/prefabs.py index 7bb17e7d..730546ba 100644 --- a/opendc-web/opendc-web-api/opendc/api/prefabs.py +++ b/opendc-web/opendc-web-api/opendc/api/prefabs.py @@ -24,7 +24,6 @@ from flask_restful import Resource from marshmallow import Schema, fields from opendc.models.prefab import Prefab as PrefabModel, PrefabSchema -from opendc.database import Database from opendc.exts import current_user, requires_auth, db @@ -56,14 +55,15 @@ class PrefabList(Resource): result = schema.load(request.json) prefab = PrefabModel(result['prefab']) - prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now())) - prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + prefab.set_property('datetimeCreated', datetime.now()) + prefab.set_property('datetimeLastEdited', datetime.now()) user_id = current_user['sub'] prefab.set_property('authorId', user_id) prefab.insert() - return {'data': prefab.obj} + data = PrefabSchema().dump(prefab.obj) + return {'data': data} class PostSchema(Schema): """ @@ -83,7 +83,8 @@ class Prefab(Resource): prefab = PrefabModel.from_id(prefab_id) prefab.check_exists() prefab.check_user_access(current_user['sub']) - return {'data': prefab.obj} + data = PrefabSchema().dump(prefab.obj) + return {'data': data} def put(self, prefab_id): """Update a prefab's name and/or contents.""" @@ -97,10 +98,11 @@ class Prefab(Resource): prefab.set_property('name', result['prefab']['name']) prefab.set_property('rack', result['prefab']['rack']) - prefab.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now())) + prefab.set_property('datetimeLastEdited', datetime.now()) prefab.update() - return {'data': prefab.obj} + data = PrefabSchema().dump(prefab.obj) + return {'data': data} def delete(self, prefab_id): """Delete this Prefab.""" @@ -111,7 +113,8 @@ class Prefab(Resource): old_object = prefab.delete() - return {'data': old_object} + data = PrefabSchema().dump(old_object) + return {'data': data} class PutSchema(Schema): """ diff --git a/opendc-web/opendc-web-api/opendc/api/projects.py b/opendc-web/opendc-web-api/opendc/api/projects.py index 8c44b680..05f02a84 100644 --- a/opendc-web/opendc-web-api/opendc/api/projects.py +++ b/opendc-web/opendc-web-api/opendc/api/projects.py @@ -27,7 +27,6 @@ from opendc.models.portfolio import Portfolio, PortfolioSchema from opendc.models.topology import Topology, TopologySchema from opendc.models.project import Project as ProjectModel, ProjectSchema from opendc.exts import current_user, requires_auth -from opendc.database import Database class ProjectList(Resource): @@ -40,7 +39,8 @@ class ProjectList(Resource): """Get the authorized projects of the user""" user_id = current_user['sub'] projects = ProjectModel.get_for_user(user_id) - return {'data': projects} + data = ProjectSchema().dump(projects, many=True) + return {'data': data} def post(self): """Create a new project, and return that new project.""" @@ -53,8 +53,8 @@ class ProjectList(Resource): topology.insert() project = ProjectModel(result['project']) - project.set_property('datetimeCreated', Database.datetime_to_string(datetime.now())) - project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + project.set_property('datetimeCreated', datetime.now()) + project.set_property('datetimeLastEdited', datetime.now()) project.set_property('topologyIds', [topology.get_id()]) project.set_property('portfolioIds', []) project.set_property('authorizations', [{'userId': user_id, 'level': 'OWN'}]) @@ -63,7 +63,8 @@ class ProjectList(Resource): topology.set_property('projectId', project.get_id()) topology.update() - return {'data': project.obj} + data = ProjectSchema().dump(project.obj) + return {'data': data} class Project(Resource): @@ -79,7 +80,8 @@ class Project(Resource): project.check_exists() project.check_user_access(current_user['sub'], False) - return {'data': project.obj} + data = ProjectSchema().dump(project.obj) + return {'data': data} def put(self, project_id): """Update a project's name.""" @@ -92,10 +94,11 @@ class Project(Resource): project.check_user_access(current_user['sub'], True) project.set_property('name', result['project']['name']) - project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + project.set_property('datetimeLastEdited', datetime.now()) project.update() - return {'data': project.obj} + data = ProjectSchema().dump(project.obj) + return {'data': data} def delete(self, project_id): """Delete this Project.""" @@ -113,8 +116,8 @@ class Project(Resource): portfolio.delete() old_object = project.delete() - - return {'data': old_object} + data = ProjectSchema().dump(old_object) + return {'data': data} class PutSchema(Schema): """ @@ -148,10 +151,11 @@ class ProjectTopologies(Resource): topology.insert() project.obj['topologyIds'].append(topology.get_id()) - project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + project.set_property('datetimeLastEdited', datetime.now()) project.update() - return {'data': topology.obj} + data = TopologySchema().dump(topology.obj) + return {'data': data} class PutSchema(Schema): """ diff --git a/opendc-web/opendc-web-api/opendc/api/scenarios.py b/opendc-web/opendc-web-api/opendc/api/scenarios.py index b566950a..234bdec1 100644 --- a/opendc-web/opendc-web-api/opendc/api/scenarios.py +++ b/opendc-web/opendc-web-api/opendc/api/scenarios.py @@ -38,7 +38,7 @@ class Scenario(Resource): scenario = ScenarioModel.from_id(scenario_id) scenario.check_exists() scenario.check_user_access(current_user['sub'], False) - data = scenario.obj + data = ScenarioSchema().dump(scenario.obj) return {'data': data} def put(self, scenario_id): @@ -54,7 +54,7 @@ class Scenario(Resource): scenario.set_property('name', result['scenario']['name']) scenario.update() - data = scenario.obj + data = ScenarioSchema().dump(scenario.obj) return {'data': data} def delete(self, scenario_id): @@ -72,7 +72,8 @@ class Scenario(Resource): portfolio.update() old_object = scenario.delete() - return {'data': old_object} + data = ScenarioSchema().dump(old_object) + return {'data': data} class PutSchema(Schema): """ diff --git a/opendc-web/opendc-web-api/opendc/api/topologies.py b/opendc-web/opendc-web-api/opendc/api/topologies.py index eedf049d..a2d3f41a 100644 --- a/opendc-web/opendc-web-api/opendc/api/topologies.py +++ b/opendc-web/opendc-web-api/opendc/api/topologies.py @@ -24,7 +24,6 @@ from flask import request from flask_restful import Resource from marshmallow import Schema, fields -from opendc.database import Database from opendc.models.project import Project from opendc.models.topology import Topology as TopologyModel, TopologySchema from opendc.exts import current_user, requires_auth @@ -43,7 +42,7 @@ class Topology(Resource): topology = TopologyModel.from_id(topology_id) topology.check_exists() topology.check_user_access(current_user['sub'], False) - data = topology.obj + data = TopologySchema().dump(topology.obj) return {'data': data} def put(self, topology_id): @@ -60,10 +59,10 @@ class Topology(Resource): topology.set_property('name', result['topology']['name']) topology.set_property('rooms', result['topology']['rooms']) - topology.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + topology.set_property('datetimeLastEdited', datetime.now()) topology.update() - data = topology.obj + data = TopologySchema().dump(topology.obj) return {'data': data} def delete(self, topology_id): @@ -84,7 +83,8 @@ class Topology(Resource): project.update() old_object = topology.delete() - return {'data': old_object} + data = TopologySchema().dump(old_object) + return {'data': data} class PutSchema(Schema): """ diff --git a/opendc-web/opendc-web-api/opendc/api/traces.py b/opendc-web/opendc-web-api/opendc/api/traces.py index f685f00c..6be8c5e5 100644 --- a/opendc-web/opendc-web-api/opendc/api/traces.py +++ b/opendc-web/opendc-web-api/opendc/api/traces.py @@ -21,7 +21,7 @@ from flask_restful import Resource from opendc.exts import requires_auth -from opendc.models.trace import Trace as TraceModel +from opendc.models.trace import Trace as TraceModel, TraceSchema class TraceList(Resource): @@ -33,7 +33,7 @@ class TraceList(Resource): def get(self): """Get all available Traces.""" traces = TraceModel.get_all() - data = traces.obj + data = TraceSchema().dump(traces.obj, many=True) return {'data': data} @@ -47,5 +47,5 @@ class Trace(Resource): """Get trace information by identifier.""" trace = TraceModel.from_id(trace_id) trace.check_exists() - data = trace.obj + data = TraceSchema().dump(trace.obj) return {'data': data} diff --git a/opendc-web/opendc-web-api/opendc/auth.py b/opendc-web/opendc-web-api/opendc/auth.py index 1870f01c..6db06fb1 100644 --- a/opendc-web/opendc-web-api/opendc/auth.py +++ b/opendc-web/opendc-web-api/opendc/auth.py @@ -42,8 +42,7 @@ def get_token(): if parts[0].lower() != "bearer": raise AuthError({ "code": "invalid_header", - "description": "Authorization header must start with" - " Bearer" + "description": "Authorization header must start with Bearer" }, 401) if len(parts) == 1: raise AuthError({"code": "invalid_header", "description": "Token not found"}, 401) diff --git a/opendc-web/opendc-web-api/opendc/database.py b/opendc-web/opendc-web-api/opendc/database.py index f9a33b66..37fd1a4d 100644 --- a/opendc-web/opendc-web-api/opendc/database.py +++ b/opendc-web/opendc-web-api/opendc/database.py @@ -19,7 +19,6 @@ # SOFTWARE. import urllib.parse -from datetime import datetime from pymongo import MongoClient @@ -90,13 +89,3 @@ class Database: The query needs to be in json format, i.e.: `{'name': prefab_name}`. """ getattr(self.opendc_db, collection).delete_many(query) - - @staticmethod - def datetime_to_string(datetime_to_convert): - """Return a database-compatible string representation of the given datetime object.""" - return datetime_to_convert.strftime(DATETIME_STRING_FORMAT) - - @staticmethod - def string_to_datetime(string_to_convert): - """Return a datetime corresponding to the given string representation.""" - return datetime.strptime(string_to_convert, DATETIME_STRING_FORMAT) diff --git a/opendc-web/opendc-web-api/opendc/exts.py b/opendc-web/opendc-web-api/opendc/exts.py index f088a29c..d24f7197 100644 --- a/opendc-web/opendc-web-api/opendc/exts.py +++ b/opendc-web/opendc-web-api/opendc/exts.py @@ -34,8 +34,7 @@ def get_auth_context(): _auth_context = AuthContext( alg=AsymmetricJwtAlgorithm(jwks_url=f"https://{os.environ['AUTH0_DOMAIN']}/.well-known/jwks.json"), issuer=f"https://{os.environ['AUTH0_DOMAIN']}/", - audience=os.environ['AUTH0_AUDIENCE'] - ) + audience=os.environ['AUTH0_AUDIENCE']) g.auth_context = _auth_context return _auth_context @@ -46,7 +45,6 @@ auth_context = LocalProxy(get_auth_context) def requires_auth(f): """Decorator to determine if the Access Token is valid. """ - @wraps(f) def decorated(*args, **kwargs): token = get_token() diff --git a/opendc-web/opendc-web-api/opendc/models/portfolio.py b/opendc-web/opendc-web-api/opendc/models/portfolio.py index aff1d3f0..1643e23e 100644 --- a/opendc-web/opendc-web-api/opendc/models/portfolio.py +++ b/opendc-web/opendc-web-api/opendc/models/portfolio.py @@ -16,7 +16,7 @@ class PortfolioSchema(Schema): """ Schema representing a portfolio. """ - _id = fields.String() + _id = fields.String(dump_only=True) projectId = fields.String() name = fields.String(required=True) scenarioIds = fields.List(fields.String()) diff --git a/opendc-web/opendc-web-api/opendc/models/prefab.py b/opendc-web/opendc-web-api/opendc/models/prefab.py index d83ef4cb..5e4b81dc 100644 --- a/opendc-web/opendc-web-api/opendc/models/prefab.py +++ b/opendc-web/opendc-web-api/opendc/models/prefab.py @@ -9,7 +9,8 @@ class PrefabSchema(Schema): """ Schema for a Prefab. """ - _id = fields.String() + _id = fields.String(dump_only=True) + authorId = fields.String(dump_only=True) name = fields.String(required=True) datetimeCreated = fields.DateTime() datetimeLastEdited = fields.DateTime() diff --git a/opendc-web/opendc-web-api/opendc/models/project.py b/opendc-web/opendc-web-api/opendc/models/project.py index ee84c73e..f2b3b564 100644 --- a/opendc-web/opendc-web-api/opendc/models/project.py +++ b/opendc-web/opendc-web-api/opendc/models/project.py @@ -1,20 +1,29 @@ -from marshmallow import Schema, fields +from marshmallow import Schema, fields, validate from werkzeug.exceptions import Forbidden from opendc.models.model import Model from opendc.exts import db +class ProjectAuthorizations(Schema): + """ + Schema representing a project authorization. + """ + userId = fields.String(required=True) + level = fields.String(required=True, validate=validate.OneOf(["VIEW", "EDIT", "OWN"])) + + class ProjectSchema(Schema): """ Schema representing a Project. """ - _id = fields.String() + _id = fields.String(dump_only=True) name = fields.String(required=True) datetimeCreated = fields.DateTime() datetimeLastEdited = fields.DateTime() topologyIds = fields.List(fields.String()) portfolioIds = fields.List(fields.String()) + authorizations = fields.List(fields.Nested(ProjectAuthorizations)) class Project(Model): diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py index 2911b1ae..658d790e 100644 --- a/opendc-web/opendc-web-api/opendc/models/scenario.py +++ b/opendc-web/opendc-web-api/opendc/models/scenario.py @@ -34,17 +34,39 @@ class OperationalSchema(Schema): schedulerName = fields.String() +class ResultSchema(Schema): + """ + Schema representing the simulation results. + """ + max_num_deployed_images = fields.List(fields.Number()) + max_cpu_demand = fields.List(fields.Number()) + max_cpu_usage = fields.List(fields.Number()) + mean_num_deployed_images = fields.List(fields.Number()) + total_failure_slices = fields.List(fields.Number()) + total_failure_vm_slices = fields.List(fields.Number()) + total_granted_burst = fields.List(fields.Number()) + total_interfered_burst = fields.List(fields.Number()) + total_overcommitted_burst = fields.List(fields.Number()) + total_power_draw = fields.List(fields.Number()) + total_requested_burst = fields.List(fields.Number()) + total_vms_failed = fields.List(fields.Number()) + total_vms_finished = fields.List(fields.Number()) + total_vms_queued = fields.List(fields.Number()) + total_vms_submitted = fields.List(fields.Number()) + + class ScenarioSchema(Schema): """ Schema representing a scenario. """ - _id = fields.String() + _id = fields.String(dump_only=True) portfolioId = fields.String() name = fields.String(required=True) simulation = fields.Nested(SimulationSchema) trace = fields.Nested(TraceSchema) topology = fields.Nested(TopologySchema) operational = fields.Nested(OperationalSchema) + results = fields.Nested(ResultSchema, dump_only=True) class Scenario(Model): diff --git a/opendc-web/opendc-web-api/opendc/models/topology.py b/opendc-web/opendc-web-api/opendc/models/topology.py index c6354ae6..71d2cade 100644 --- a/opendc-web/opendc-web-api/opendc/models/topology.py +++ b/opendc-web/opendc-web-api/opendc/models/topology.py @@ -72,8 +72,8 @@ class TopologySchema(Schema): """ Schema representing a datacenter topology. """ - _id = fields.String() - projectId = fields.String() + _id = fields.String(dump_only=True) + projectId = fields.String(dump_only=True) name = fields.String(required=True) rooms = fields.List(fields.Nested(RoomSchema), required=True) diff --git a/opendc-web/opendc-web-api/opendc/models/trace.py b/opendc-web/opendc-web-api/opendc/models/trace.py index 2f6e4926..69287f29 100644 --- a/opendc-web/opendc-web-api/opendc/models/trace.py +++ b/opendc-web/opendc-web-api/opendc/models/trace.py @@ -1,6 +1,15 @@ +from marshmallow import Schema, fields + from opendc.models.model import Model +class TraceSchema(Schema): + """Schema for a Trace.""" + _id = fields.String(dump_only=True) + name = fields.String() + type = fields.String() + + class Trace(Model): """Model representing a Trace.""" -- cgit v1.2.3 From a2a5979bfb392565b55e489b6020aa391e782eb0 Mon Sep 17 00:00:00 2001 From: Fabian Mastenbroek Date: Fri, 2 Jul 2021 16:14:52 +0200 Subject: api: Add endpoint for simulation jobs This change adds an API endpoint for simulation jobs which allows API consumers to manage simulation jobs without needing direct database access that is currently needed for the web runner. --- opendc-web/opendc-web-api/app.py | 3 + opendc-web/opendc-web-api/opendc/api/jobs.py | 105 ++++++++++++++ opendc-web/opendc-web-api/opendc/database.py | 8 +- .../opendc-web-api/opendc/models/scenario.py | 52 +++---- opendc-web/opendc-web-api/static/schema.yml | 151 +++++++++++++++++++-- opendc-web/opendc-web-api/tests/api/test_jobs.py | 139 +++++++++++++++++++ 6 files changed, 419 insertions(+), 39 deletions(-) create mode 100644 opendc-web/opendc-web-api/opendc/api/jobs.py create mode 100644 opendc-web/opendc-web-api/tests/api/test_jobs.py (limited to 'opendc-web/opendc-web-api') diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py index 96a1ca7a..36c80b7a 100755 --- a/opendc-web/opendc-web-api/app.py +++ b/opendc-web/opendc-web-api/app.py @@ -10,6 +10,7 @@ from flask_restful import Api from flask_swagger_ui import get_swaggerui_blueprint from marshmallow import ValidationError +from opendc.api.jobs import JobList, Job from opendc.api.portfolios import Portfolio, PortfolioScenarios from opendc.api.prefabs import Prefab, PrefabList from opendc.api.projects import ProjectList, Project, ProjectTopologies, ProjectPortfolios @@ -60,6 +61,8 @@ def setup_api(app): api.add_resource(TraceList, '/traces/') api.add_resource(Trace, '/traces/') api.add_resource(SchedulerList, '/schedulers/') + api.add_resource(JobList, '/jobs/') + api.add_resource(Job, '/jobs/') @app.errorhandler(AuthError) def handle_auth_error(ex): diff --git a/opendc-web/opendc-web-api/opendc/api/jobs.py b/opendc-web/opendc-web-api/opendc/api/jobs.py new file mode 100644 index 00000000..5feaea16 --- /dev/null +++ b/opendc-web/opendc-web-api/opendc/api/jobs.py @@ -0,0 +1,105 @@ +# Copyright (c) 2021 AtLarge Research +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from flask import request +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.models.scenario import Scenario + + +def convert_to_job(scenario): + """Convert a scenario to a job. + """ + return JobSchema().dump({ + '_id': scenario['_id'], + 'scenarioId': scenario['_id'], + 'state': scenario['simulation']['state'], + 'heartbeat': scenario['simulation'].get('heartbeat', None), + 'results': scenario.get('results', {}) + }) + + +class JobSchema(Schema): + """ + Schema representing a simulation job. + """ + _id = fields.String(dump_only=True) + scenarioId = fields.String(dump_only=True) + state = fields.String(required=True, + validate=validate.OneOf(["QUEUED", "CLAIMED", "RUNNING", "FINISHED", "FAILED"])) + heartbeat = fields.DateTime() + results = fields.Dict() + + +class JobList(Resource): + """ + Resource representing the list of available jobs. + """ + method_decorators = [requires_auth] + + def get(self): + """Get all available jobs.""" + jobs = Scenario.get_jobs() + data = list(map(convert_to_job, jobs.obj)) + return {'data': data} + + +class Job(Resource): + """ + Resource representing a single job. + """ + method_decorators = [requires_auth] + + def get(self, job_id): + """Get the details of a single job.""" + job = Scenario.from_id(job_id) + job.check_exists() + data = convert_to_job(job.obj) + return {'data': data} + + def post(self, job_id): + """Update the details of a single job.""" + action = JobSchema(only=('state', 'results')).load(request.json) + + job = Scenario.from_id(job_id) + job.check_exists() + + old_state = job.obj['simulation']['state'] + new_state = action['state'] + + if old_state == new_state: + data = job.update_state(new_state) + elif (old_state, new_state) == ('QUEUED', 'CLAIMED'): + data = job.update_state('CLAIMED') + elif (old_state, new_state) == ('CLAIMED', 'RUNNING'): + data = job.update_state('RUNNING') + elif (old_state, new_state) == ('RUNNING', 'FINISHED'): + data = job.update_state('FINISHED', results=action.get('results', None)) + elif old_state in ('CLAIMED', 'RUNNING') and new_state == 'FAILED': + data = job.update_state('FAILED') + else: + raise BadRequest('Invalid state transition') + + if not data: + raise Conflict('State conflict') + + return {'data': convert_to_job(data)} diff --git a/opendc-web/opendc-web-api/opendc/database.py b/opendc-web/opendc-web-api/opendc/database.py index 37fd1a4d..dd6367f2 100644 --- a/opendc-web/opendc-web-api/opendc/database.py +++ b/opendc-web/opendc-web-api/opendc/database.py @@ -20,7 +20,7 @@ import urllib.parse -from pymongo import MongoClient +from pymongo import MongoClient, ReturnDocument DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S' CONNECTION_POOL = None @@ -76,6 +76,12 @@ class Database: """Updates an existing object.""" return getattr(self.opendc_db, collection).update({'_id': _id}, obj) + def fetch_and_update(self, query, update, collection): + """Updates an existing object.""" + return getattr(self.opendc_db, collection).find_one_and_update(query, + update, + return_document=ReturnDocument.AFTER) + def delete_one(self, query, collection): """Deletes one object matching the given query. diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py index 658d790e..0fb6c453 100644 --- a/opendc-web/opendc-web-api/opendc/models/scenario.py +++ b/opendc-web/opendc-web-api/opendc/models/scenario.py @@ -1,15 +1,12 @@ +from datetime import datetime + from marshmallow import Schema, fields + +from opendc.exts import db from opendc.models.model import Model from opendc.models.portfolio import Portfolio -class SimulationSchema(Schema): - """ - Simulation details. - """ - state = fields.String() - - class TraceSchema(Schema): """ Schema for specifying the trace of a scenario. @@ -34,27 +31,6 @@ class OperationalSchema(Schema): schedulerName = fields.String() -class ResultSchema(Schema): - """ - Schema representing the simulation results. - """ - max_num_deployed_images = fields.List(fields.Number()) - max_cpu_demand = fields.List(fields.Number()) - max_cpu_usage = fields.List(fields.Number()) - mean_num_deployed_images = fields.List(fields.Number()) - total_failure_slices = fields.List(fields.Number()) - total_failure_vm_slices = fields.List(fields.Number()) - total_granted_burst = fields.List(fields.Number()) - total_interfered_burst = fields.List(fields.Number()) - total_overcommitted_burst = fields.List(fields.Number()) - total_power_draw = fields.List(fields.Number()) - total_requested_burst = fields.List(fields.Number()) - total_vms_failed = fields.List(fields.Number()) - total_vms_finished = fields.List(fields.Number()) - total_vms_queued = fields.List(fields.Number()) - total_vms_submitted = fields.List(fields.Number()) - - class ScenarioSchema(Schema): """ Schema representing a scenario. @@ -62,11 +38,9 @@ class ScenarioSchema(Schema): _id = fields.String(dump_only=True) portfolioId = fields.String() name = fields.String(required=True) - simulation = fields.Nested(SimulationSchema) trace = fields.Nested(TraceSchema) topology = fields.Nested(TopologySchema) operational = fields.Nested(OperationalSchema) - results = fields.Nested(ResultSchema, dump_only=True) class Scenario(Model): @@ -84,3 +58,21 @@ class Scenario(Model): """ portfolio = Portfolio.from_id(self.obj['portfolioId']) portfolio.check_user_access(user_id, edit_access) + + @classmethod + def get_jobs(cls): + """Obtain the scenarios that have been queued. + """ + return cls(db.fetch_all({'simulation.state': 'QUEUED'}, cls.collection_name)) + + def update_state(self, new_state, results=None): + """Atomically update the state of the Scenario. + """ + update = {'$set': {'simulation.state': new_state, 'simulation.heartbeat': datetime.now()}} + if results: + update['$set']['results'] = results + return db.fetch_and_update( + query={'_id': self.obj['_id'], 'simulation.state': self.obj['simulation']['state']}, + update=update, + collection=self.collection_name + ) diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml index 6e0bddfd..5a8c6825 100644 --- a/opendc-web/opendc-web-api/static/schema.yml +++ b/opendc-web/opendc-web-api/static/schema.yml @@ -965,7 +965,7 @@ paths: application/json: schema: properties: - project: + prefab: $ref: "#/components/schemas/Prefab" description: Prefab's new properties. required: true @@ -1040,6 +1040,135 @@ paths: "application/json": schema: $ref: "#/components/schemas/NotFound" + /jobs: + get: + tags: + - jobs + description: Get all available jobs to run. + responses: + "200": + description: Successfully retrieved available jobs. + content: + "application/json": + schema: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/Job" + "401": + description: Unauthorized. + content: + "application/json": + schema: + $ref: "#/components/schemas/Unauthorized" + "/jobs/{jobId}": + get: + tags: + - jobs + description: Get this Job. + parameters: + - name: jobId + in: path + description: Job's ID. + required: true + schema: + type: string + responses: + "200": + description: Successfully retrieved Job. + content: + "application/json": + schema: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/Job" + "401": + description: Unauthorized. + content: + "application/json": + schema: + $ref: "#/components/schemas/Unauthorized" + "403": + description: Forbidden from retrieving Job. + content: + "application/json": + schema: + $ref: "#/components/schemas/Forbidden" + "404": + description: Job not found. + content: + "application/json": + schema: + $ref: "#/components/schemas/NotFound" + post: + tags: + - jobs + description: Update this Job. + parameters: + - name: jobId + in: path + description: Job's ID. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + job: + $ref: "#/components/schemas/Job" + description: Job's new properties. + required: true + responses: + "200": + description: Successfully updated Job. + content: + "application/json": + schema: + type: object + required: + - data + properties: + data: + $ref: "#/components/schemas/Job" + "400": + description: Missing or incorrectly typed parameter. + content: + "application/json": + schema: + $ref: "#/components/schemas/Invalid" + "401": + description: Unauthorized. + content: + "application/json": + schema: + $ref: "#/components/schemas/Unauthorized" + "403": + description: Forbidden from retrieving Job. + content: + "application/json": + schema: + $ref: "#/components/schemas/Forbidden" + "404": + description: Job not found. + content: + "application/json": + schema: + $ref: "#/components/schemas/NotFound" + "409": + description: State conflict. + content: + "application/json": + schema: + $ref: "#/components/schemas/Invalid" components: securitySchemes: auth0: @@ -1261,13 +1390,6 @@ components: type: string name: type: string - simulation: - type: object - properties: - state: - type: string - results: - type: object trace: type: object properties: @@ -1289,6 +1411,19 @@ components: type: boolean schedulerName: type: string + Job: + type: object + properties: + _id: + type: string + scenarioId: + type: string + state: + type: string + heartbeat: + type: string + results: + type: object Trace: type: object properties: diff --git a/opendc-web/opendc-web-api/tests/api/test_jobs.py b/opendc-web/opendc-web-api/tests/api/test_jobs.py new file mode 100644 index 00000000..2efe6933 --- /dev/null +++ b/opendc-web/opendc-web-api/tests/api/test_jobs.py @@ -0,0 +1,139 @@ +# Copyright (c) 2021 AtLarge Research +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# +from datetime import datetime + +from opendc.exts import db + +test_id = 24 * '1' +test_id_2 = 24 * '2' + + +def test_get_jobs(client, mocker): + mocker.patch.object(db, 'fetch_all', return_value=[ + {'_id': 'a', 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}} + ]) + res = client.get('/jobs/') + assert '200' in res.status + + +def test_get_job_non_existing(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value=None) + assert '404' in client.get(f'/jobs/{test_id}').status + + +def test_get_job(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': 'a', 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'} + }) + res = client.get(f'/jobs/{test_id}') + assert '200' in res.status + + +def test_update_job_nop(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'} + }) + update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', + 'simulation': {'state': 'QUEUED', 'heartbeat': datetime.now()} + }) + res = client.post(f'/jobs/{test_id}', json={'state': 'QUEUED'}) + assert '200' in res.status + update_mock.assert_called_once() + + +def test_update_job_invalid_state(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'} + }) + res = client.post(f'/jobs/{test_id}', json={'state': 'FINISHED'}) + assert '400' in res.status + + +def test_update_job_claim(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'} + }) + update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', + 'simulation': {'state': 'CLAIMED', 'heartbeat': datetime.now()} + }) + res = client.post(f'/jobs/{test_id}', json={'state': 'CLAIMED'}) + assert '200' in res.status + update_mock.assert_called_once() + + +def test_update_job_conflict(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'} + }) + update_mock = mocker.patch.object(db, 'fetch_and_update', return_value=None) + res = client.post(f'/jobs/{test_id}', json={'state': 'CLAIMED'}) + assert '409' in res.status + update_mock.assert_called_once() + + +def test_update_job_run(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'CLAIMED'} + }) + update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', + 'simulation': {'state': 'RUNNING', 'heartbeat': datetime.now()} + }) + res = client.post(f'/jobs/{test_id}', json={'state': 'RUNNING'}) + assert '200' in res.status + update_mock.assert_called_once() + + +def test_update_job_finished(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'RUNNING'} + }) + update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', + 'simulation': {'state': 'FINISHED', 'heartbeat': datetime.now()} + }) + res = client.post(f'/jobs/{test_id}', json={'state': 'FINISHED'}) + assert '200' in res.status + update_mock.assert_called_once() + + +def test_update_job_failed(client, mocker): + mocker.patch.object(db, 'fetch_one', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'RUNNING'} + }) + update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={ + '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', + 'simulation': {'state': 'FAILED', 'heartbeat': datetime.now()} + }) + res = client.post(f'/jobs/{test_id}', json={'state': 'FAILED'}) + assert '200' in res.status + update_mock.assert_called_once() -- cgit v1.2.3 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(-) (limited to 'opendc-web/opendc-web-api') 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