From 7bb6343ed3cb38afc5c2977a8fb1946241d37e41 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Fri, 18 Oct 2024 12:54:08 -0500 Subject: [PATCH 1/6] Add checks for restricted queues to authorize clients --- server/src/api/v1.py | 60 ++++++++++++++++++++++------------------ server/src/database.py | 8 ++++++ server/tests/conftest.py | 8 ++++++ server/tests/test_v1.py | 49 ++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 27 deletions(-) diff --git a/server/src/api/v1.py b/server/src/api/v1.py index 7ac5c41b..9f0b781d 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -105,13 +105,17 @@ def has_attachments(data: dict) -> bool: ) -def check_token_priority_permission( +def check_token_permissions( auth_token: str, secret_key: str, priority: int, queue: str ) -> bool: """ Validates token received from client and checks if it can push a job to the queue with the requested priority """ + is_queue_restricted = database.check_queue_restricted(queue) + if not is_queue_restricted and priority == 0: + return True + if auth_token is None: abort(401, "Unauthorized") try: @@ -130,7 +134,10 @@ def check_token_priority_permission( star_priority = max_priority_dict.get("*", 0) queue_priority = max_priority_dict.get(queue, 0) max_priority = max(star_priority, queue_priority) - return max_priority >= priority + allowed_queues = decoded_jwt.get("allowed_queues", []) + return max_priority >= priority and ( + not is_queue_restricted or queue in allowed_queues + ) def job_builder(data: dict, auth_token: str): @@ -156,27 +163,25 @@ def job_builder(data: dict, auth_token: str): if has_attachments(data): data["attachments_status"] = "waiting" - if "job_priority" in data: - priority_level = data["job_priority"] - job_queue = data["job_queue"] - allowed = check_token_priority_permission( - auth_token, - os.environ.get("JWT_SIGNING_KEY"), - priority_level, - job_queue, + priority_level = data.get("job_priority", 0) + job_queue = data["job_queue"] + allowed = check_token_permissions( + auth_token, + os.environ.get("JWT_SIGNING_KEY"), + priority_level, + job_queue, + ) + if not allowed: + abort( + 403, + ( + f"Not enough permissions to push to {job_queue}", + f"with priority {priority_level}", + ), ) - if not allowed: - abort( - 403, - ( - f"Not enough permissions to push to {job_queue}", - f"with priority {priority_level}", - ), - ) - job["job_priority"] = priority_level - data.pop("job_priority") - else: - job["job_priority"] = 0 + job["job_priority"] = priority_level + data.pop("job_priority", None) + job["job_id"] = job_id job["job_data"] = data return job @@ -712,16 +717,18 @@ def queue_wait_time_percentiles_get(): return queue_percentile_data -def generate_token(max_priority, secret_key): +def generate_token(allowed_resources, secret_key): """Generates JWT token with queue permission given a secret key""" expiration_time = datetime.utcnow() + timedelta(seconds=2) token_payload = { "exp": expiration_time, "iat": datetime.now(timezone.utc), # Issued at time "sub": "access_token", - "max_priority": max_priority, } - + if "max_priority" in allowed_resources: + token_payload["max_priority"] = allowed_resources["max_priority"] + if "allowed_queues" in allowed_resources: + token_payload["allowed_queues"] = allowed_resources["allowed_queues"] token = jwt.encode(token_payload, secret_key, algorithm="HS256") return token @@ -744,8 +751,7 @@ def validate_client_key_pair(client_id: str, client_key: str): client_permissions_entry["client_secret_hash"].encode("utf8"), ): return None - max_priority = client_permissions_entry["max_priority"] - return max_priority + return client_permissions_entry @v1.post("/oauth2/token") diff --git a/server/src/database.py b/server/src/database.py index 4507cc08..5e276803 100644 --- a/server/src/database.py +++ b/server/src/database.py @@ -327,3 +327,11 @@ def get_provision_log( if provision_log_entries else [] ) + + +def check_queue_restricted(queue: str) -> bool: + """Checks if queue is restricted""" + queue_count = mongo.db.restricted_queues.count_documents( + {"queue_name": queue} + ) + return queue_count != 0 diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 2b298b25..849ec9a3 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -83,11 +83,19 @@ def mongo_app_with_permissions(mongo_app): "myqueue": 100, "myqueue2": 200, } + allowed_queues = ["rqueue1", "rqueue2"] mongo.client_permissions.insert_one( { "client_id": client_id, "client_secret_hash": client_key_hash, "max_priority": max_priority, + "allowed_queues": allowed_queues, } ) + restricted_queues = [ + {"queue_name": "rqueue1"}, + {"queue_name": "rqueue2"}, + {"queue_name": "rqueue3"}, + ] + mongo.restricted_queues.insert_many(restricted_queues) yield app, mongo, client_id, client_key, max_priority diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index 99eccb25..c37bd73b 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -972,3 +972,52 @@ def test_job_position_get_with_priority(mongo_app_with_permissions): assert job_positions[0] == "2" assert job_positions[1] == "0" assert job_positions[2] == "1" + + +def test_restricted_queue_allowed(mongo_app_with_permissions): + """ + Tests that jobs that submit to a restricted queue are accepted + when the token allows that queue + """ + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + # rqueue1 is a restricted queue that is allowed for this client + job = {"job_queue": "rqueue1"} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 200 == job_response.status_code + + +def test_restricted_queue_reject(mongo_app_with_permissions): + """ + Tests that jobs that submit to a restricted queue are rejected + when the client is not allowed + """ + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + # rqueue3 is a restricted queue that is not allowed for this client + job = {"job_queue": "rqueue3"} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 403 == job_response.status_code + + +def test_restricted_queue_reject_no_token(mongo_app_with_permissions): + """ + Tests that jobs that submit to a restricted queue are rejected + when no token is included + """ + app, _, _, _, _ = mongo_app_with_permissions + job = {"job_queue": "rqueue1"} + job_response = app.post("/v1/job", json=job) + assert 401 == job_response.status_code From 5a207de9b8934c5cbf47281123ff1fed9dfc0e33 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Tue, 29 Oct 2024 08:36:27 -0500 Subject: [PATCH 2/6] Update CLI to work for restricted queues --- cli/testflinger_cli/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/testflinger_cli/__init__.py b/cli/testflinger_cli/__init__.py index 699d5c68..a28e4dd2 100644 --- a/cli/testflinger_cli/__init__.py +++ b/cli/testflinger_cli/__init__.py @@ -421,10 +421,14 @@ def submit(self): except FileNotFoundError: sys.exit(f"File not found: {self.args.filename}") job_dict = yaml.safe_load(data) - if "job_priority" in job_dict: - jwt = self.authenticate_with_server() + jwt = self.authenticate_with_server() + if jwt is not None: auth_headers = {"Authorization": jwt} else: + if "job_priority" in job_dict: + sys.exit( + "Must provide client id and secret key for priority jobs" + ) auth_headers = None attachments_data = self.extract_attachment_data(job_dict) @@ -541,7 +545,7 @@ def authenticate_with_server(self): and return JWT with permissions """ if self.client_id is None or self.secret_key is None: - sys.exit("Must provide client id and secret key for priority jobs") + return None try: jwt = self.client.authenticate(self.client_id, self.secret_key) From 95c6a2584914207cb4aafca33ad417c980670dd3 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Fri, 1 Nov 2024 05:57:42 -0500 Subject: [PATCH 3/6] Move priority/restricted queue tests into a seperate test file --- server/tests/test_v1.py | 298 +----------------------- server/tests/test_v1_authorization.py | 318 ++++++++++++++++++++++++++ 2 files changed, 319 insertions(+), 297 deletions(-) create mode 100644 server/tests/test_v1_authorization.py diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py index c37bd73b..99c287c9 100644 --- a/server/tests/test_v1.py +++ b/server/tests/test_v1.py @@ -17,13 +17,10 @@ Unit tests for Testflinger v1 API """ -from datetime import datetime, timedelta +from datetime import datetime from io import BytesIO import json import os -import base64 - -import jwt from src.api import v1 @@ -728,296 +725,3 @@ def test_get_queue_wait_times(mongo_app): assert len(output.json) == 2 assert output.json["queue1"]["50"] == 3.0 assert output.json["queue2"]["50"] == 30.0 - - -def create_auth_header(client_id: str, client_key: str) -> dict: - """ - Creates authorization header with base64 encoded client_id - and client key using the Basic scheme - """ - id_key_pair = f"{client_id}:{client_key}" - base64_encoded_pair = base64.b64encode(id_key_pair.encode("utf-8")).decode( - "utf-8" - ) - return {"Authorization": f"Basic {base64_encoded_pair}"} - - -def test_retrieve_token(mongo_app_with_permissions): - """Tests authentication endpoint which returns JWT with permissions""" - app, _, client_id, client_key, max_priority = mongo_app_with_permissions - output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - assert output.status_code == 200 - token = output.data - decoded_token = jwt.decode( - token, - os.environ.get("JWT_SIGNING_KEY"), - algorithms="HS256", - options={"require": ["exp", "iat", "sub", "max_priority"]}, - ) - assert decoded_token["max_priority"] == max_priority - - -def test_retrieve_token_invalid_client_id(mongo_app_with_permissions): - """ - Tests that authentication endpoint returns 401 error code - when receiving invalid client key - """ - app, _, _, client_key, _ = mongo_app_with_permissions - client_id = "my_wrong_id" - output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - assert output.status_code == 401 - - -def test_retrieve_token_invalid_client_key(mongo_app_with_permissions): - """ - Tests that authentication endpoint returns 401 error code - when receiving invalid client key - """ - app, _, client_id, _, _ = mongo_app_with_permissions - client_key = "my_wrong_key" - output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - assert output.status_code == 401 - - -def test_job_with_priority(mongo_app_with_permissions): - """Tests submission of priority job with valid token""" - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - job = {"job_queue": "myqueue2", "job_priority": 200} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 200 == job_response.status_code - - -def test_star_priority(mongo_app_with_permissions): - """ - Tests submission of priority job for a generic queue - with star priority permissions - """ - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - job = {"job_queue": "mygenericqueue", "job_priority": 1} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 200 == job_response.status_code - - -def test_priority_no_token(mongo_app_with_permissions): - """Tests rejection of priority job with no token""" - app, _, _, _, _ = mongo_app_with_permissions - job = {"job_queue": "myqueue2", "job_priority": 200} - job_response = app.post("/v1/job", json=job) - assert 401 == job_response.status_code - - -def test_priority_invalid_queue(mongo_app_with_permissions): - """Tests rejection of priority job with invalid queue""" - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - job = {"job_queue": "myinvalidqueue", "job_priority": 200} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 403 == job_response.status_code - - -def test_priority_expired_token(mongo_app_with_permissions): - """Tests rejection of priority job with expired token""" - app, _, _, _, _ = mongo_app_with_permissions - secret_key = os.environ.get("JWT_SIGNING_KEY") - expired_token_payload = { - "exp": datetime.utcnow() - timedelta(seconds=2), - "iat": datetime.utcnow() - timedelta(seconds=4), - "sub": "access_token", - "max_priority": {}, - } - token = jwt.encode(expired_token_payload, secret_key, algorithm="HS256") - job = {"job_queue": "myqueue", "job_priority": 100} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 403 == job_response.status_code - assert "Token has expired" in job_response.text - - -def test_missing_fields_in_token(mongo_app_with_permissions): - """Tests rejection of priority job with token with missing fields""" - app, _, _, _, _ = mongo_app_with_permissions - secret_key = os.environ.get("JWT_SIGNING_KEY") - incomplete_token_payload = { - "max_priority": {}, - } - token = jwt.encode(incomplete_token_payload, secret_key, algorithm="HS256") - job = {"job_queue": "myqueue", "job_priority": 100} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 403 == job_response.status_code - assert "Invalid Token" in job_response.text - - -def test_job_get_with_priority(mongo_app_with_permissions): - """Tests job get returns job with highest job priority""" - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - jobs = [ - {"job_queue": "myqueue2"}, - {"job_queue": "myqueue2", "job_priority": 200}, - {"job_queue": "myqueue2", "job_priority": 100}, - ] - job_ids = [] - for job in jobs: - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - job_id = job_response.json.get("job_id") - job_ids.append(job_id) - returned_job_ids = [] - for _ in range(len(jobs)): - job_get_response = app.get("/v1/job?queue=myqueue2") - job_id = job_get_response.json.get("job_id") - returned_job_ids.append(job_id) - assert returned_job_ids[0] == job_ids[1] - assert returned_job_ids[1] == job_ids[2] - assert returned_job_ids[2] == job_ids[0] - - -def test_job_get_with_priority_multiple_queues(mongo_app_with_permissions): - """ - Tests job get returns job with highest job priority when jobs are - submitted across different queues - """ - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - jobs = [ - {"job_queue": "myqueue3"}, - {"job_queue": "myqueue2", "job_priority": 200}, - {"job_queue": "myqueue", "job_priority": 100}, - ] - job_ids = [] - for job in jobs: - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - job_id = job_response.json.get("job_id") - job_ids.append(job_id) - returned_job_ids = [] - for _ in range(len(jobs)): - job_get_response = app.get( - "/v1/job?queue=myqueue&queue=myqueue2&queue=myqueue3" - ) - job_id = job_get_response.json.get("job_id") - returned_job_ids.append(job_id) - assert returned_job_ids[0] == job_ids[1] - assert returned_job_ids[1] == job_ids[2] - assert returned_job_ids[2] == job_ids[0] - - -def test_job_position_get_with_priority(mongo_app_with_permissions): - """Tests job position get returns correct position with priority""" - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - jobs = [ - {"job_queue": "myqueue2"}, - {"job_queue": "myqueue2", "job_priority": 200}, - {"job_queue": "myqueue2", "job_priority": 100}, - ] - job_ids = [] - for job in jobs: - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - job_id = job_response.json.get("job_id") - job_ids.append(job_id) - - job_positions = [] - for job_id in job_ids: - job_positions.append(app.get(f"/v1/job/{job_id}/position").text) - - assert job_positions[0] == "2" - assert job_positions[1] == "0" - assert job_positions[2] == "1" - - -def test_restricted_queue_allowed(mongo_app_with_permissions): - """ - Tests that jobs that submit to a restricted queue are accepted - when the token allows that queue - """ - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - # rqueue1 is a restricted queue that is allowed for this client - job = {"job_queue": "rqueue1"} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 200 == job_response.status_code - - -def test_restricted_queue_reject(mongo_app_with_permissions): - """ - Tests that jobs that submit to a restricted queue are rejected - when the client is not allowed - """ - app, _, client_id, client_key, _ = mongo_app_with_permissions - authenticate_output = app.post( - "/v1/oauth2/token", - headers=create_auth_header(client_id, client_key), - ) - token = authenticate_output.data.decode("utf-8") - # rqueue3 is a restricted queue that is not allowed for this client - job = {"job_queue": "rqueue3"} - job_response = app.post( - "/v1/job", json=job, headers={"Authorization": token} - ) - assert 403 == job_response.status_code - - -def test_restricted_queue_reject_no_token(mongo_app_with_permissions): - """ - Tests that jobs that submit to a restricted queue are rejected - when no token is included - """ - app, _, _, _, _ = mongo_app_with_permissions - job = {"job_queue": "rqueue1"} - job_response = app.post("/v1/job", json=job) - assert 401 == job_response.status_code diff --git a/server/tests/test_v1_authorization.py b/server/tests/test_v1_authorization.py new file mode 100644 index 00000000..b05ebd08 --- /dev/null +++ b/server/tests/test_v1_authorization.py @@ -0,0 +1,318 @@ +# Copyright (C) 2024 Canonical +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +""" +Unit tests for Testflinger v1 API relating to job priority and +restricted queues +""" + +from datetime import datetime, timedelta +import os +import base64 + +import jwt + + +def create_auth_header(client_id: str, client_key: str) -> dict: + """ + Creates authorization header with base64 encoded client_id + and client key using the Basic scheme + """ + id_key_pair = f"{client_id}:{client_key}" + base64_encoded_pair = base64.b64encode(id_key_pair.encode("utf-8")).decode( + "utf-8" + ) + return {"Authorization": f"Basic {base64_encoded_pair}"} + + +def test_retrieve_token(mongo_app_with_permissions): + """Tests authentication endpoint which returns JWT with permissions""" + app, _, client_id, client_key, max_priority = mongo_app_with_permissions + output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + assert output.status_code == 200 + token = output.data + decoded_token = jwt.decode( + token, + os.environ.get("JWT_SIGNING_KEY"), + algorithms="HS256", + options={"require": ["exp", "iat", "sub", "max_priority"]}, + ) + assert decoded_token["max_priority"] == max_priority + + +def test_retrieve_token_invalid_client_id(mongo_app_with_permissions): + """ + Tests that authentication endpoint returns 401 error code + when receiving invalid client key + """ + app, _, _, client_key, _ = mongo_app_with_permissions + client_id = "my_wrong_id" + output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + assert output.status_code == 401 + + +def test_retrieve_token_invalid_client_key(mongo_app_with_permissions): + """ + Tests that authentication endpoint returns 401 error code + when receiving invalid client key + """ + app, _, client_id, _, _ = mongo_app_with_permissions + client_key = "my_wrong_key" + output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + assert output.status_code == 401 + + +def test_job_with_priority(mongo_app_with_permissions): + """Tests submission of priority job with valid token""" + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + job = {"job_queue": "myqueue2", "job_priority": 200} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 200 == job_response.status_code + + +def test_star_priority(mongo_app_with_permissions): + """ + Tests submission of priority job for a generic queue + with star priority permissions + """ + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + job = {"job_queue": "mygenericqueue", "job_priority": 1} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 200 == job_response.status_code + + +def test_priority_no_token(mongo_app_with_permissions): + """Tests rejection of priority job with no token""" + app, _, _, _, _ = mongo_app_with_permissions + job = {"job_queue": "myqueue2", "job_priority": 200} + job_response = app.post("/v1/job", json=job) + assert 401 == job_response.status_code + + +def test_priority_invalid_queue(mongo_app_with_permissions): + """Tests rejection of priority job with invalid queue""" + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + job = {"job_queue": "myinvalidqueue", "job_priority": 200} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 403 == job_response.status_code + + +def test_priority_expired_token(mongo_app_with_permissions): + """Tests rejection of priority job with expired token""" + app, _, _, _, _ = mongo_app_with_permissions + secret_key = os.environ.get("JWT_SIGNING_KEY") + expired_token_payload = { + "exp": datetime.utcnow() - timedelta(seconds=2), + "iat": datetime.utcnow() - timedelta(seconds=4), + "sub": "access_token", + "max_priority": {}, + } + token = jwt.encode(expired_token_payload, secret_key, algorithm="HS256") + job = {"job_queue": "myqueue", "job_priority": 100} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 403 == job_response.status_code + assert "Token has expired" in job_response.text + + +def test_missing_fields_in_token(mongo_app_with_permissions): + """Tests rejection of priority job with token with missing fields""" + app, _, _, _, _ = mongo_app_with_permissions + secret_key = os.environ.get("JWT_SIGNING_KEY") + incomplete_token_payload = { + "max_priority": {}, + } + token = jwt.encode(incomplete_token_payload, secret_key, algorithm="HS256") + job = {"job_queue": "myqueue", "job_priority": 100} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 403 == job_response.status_code + assert "Invalid Token" in job_response.text + + +def test_job_get_with_priority(mongo_app_with_permissions): + """Tests job get returns job with highest job priority""" + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + jobs = [ + {"job_queue": "myqueue2"}, + {"job_queue": "myqueue2", "job_priority": 200}, + {"job_queue": "myqueue2", "job_priority": 100}, + ] + job_ids = [] + for job in jobs: + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + job_id = job_response.json.get("job_id") + job_ids.append(job_id) + returned_job_ids = [] + for _ in range(len(jobs)): + job_get_response = app.get("/v1/job?queue=myqueue2") + job_id = job_get_response.json.get("job_id") + returned_job_ids.append(job_id) + assert returned_job_ids[0] == job_ids[1] + assert returned_job_ids[1] == job_ids[2] + assert returned_job_ids[2] == job_ids[0] + + +def test_job_get_with_priority_multiple_queues(mongo_app_with_permissions): + """ + Tests job get returns job with highest job priority when jobs are + submitted across different queues + """ + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + jobs = [ + {"job_queue": "myqueue3"}, + {"job_queue": "myqueue2", "job_priority": 200}, + {"job_queue": "myqueue", "job_priority": 100}, + ] + job_ids = [] + for job in jobs: + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + job_id = job_response.json.get("job_id") + job_ids.append(job_id) + returned_job_ids = [] + for _ in range(len(jobs)): + job_get_response = app.get( + "/v1/job?queue=myqueue&queue=myqueue2&queue=myqueue3" + ) + job_id = job_get_response.json.get("job_id") + returned_job_ids.append(job_id) + assert returned_job_ids[0] == job_ids[1] + assert returned_job_ids[1] == job_ids[2] + assert returned_job_ids[2] == job_ids[0] + + +def test_job_position_get_with_priority(mongo_app_with_permissions): + """Tests job position get returns correct position with priority""" + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + jobs = [ + {"job_queue": "myqueue2"}, + {"job_queue": "myqueue2", "job_priority": 200}, + {"job_queue": "myqueue2", "job_priority": 100}, + ] + job_ids = [] + for job in jobs: + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + job_id = job_response.json.get("job_id") + job_ids.append(job_id) + + job_positions = [] + for job_id in job_ids: + job_positions.append(app.get(f"/v1/job/{job_id}/position").text) + + assert job_positions[0] == "2" + assert job_positions[1] == "0" + assert job_positions[2] == "1" + + +def test_restricted_queue_allowed(mongo_app_with_permissions): + """ + Tests that jobs that submit to a restricted queue are accepted + when the token allows that queue + """ + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + # rqueue1 is a restricted queue that is allowed for this client + job = {"job_queue": "rqueue1"} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 200 == job_response.status_code + + +def test_restricted_queue_reject(mongo_app_with_permissions): + """ + Tests that jobs that submit to a restricted queue are rejected + when the client is not allowed + """ + app, _, client_id, client_key, _ = mongo_app_with_permissions + authenticate_output = app.post( + "/v1/oauth2/token", + headers=create_auth_header(client_id, client_key), + ) + token = authenticate_output.data.decode("utf-8") + # rqueue3 is a restricted queue that is not allowed for this client + job = {"job_queue": "rqueue3"} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 403 == job_response.status_code + + +def test_restricted_queue_reject_no_token(mongo_app_with_permissions): + """ + Tests that jobs that submit to a restricted queue are rejected + when no token is included + """ + app, _, _, _, _ = mongo_app_with_permissions + job = {"job_queue": "rqueue1"} + job_response = app.post("/v1/job", json=job) + assert 401 == job_response.status_code From a29ae647ce15559665b27ab28cd157db1bee25c4 Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Fri, 1 Nov 2024 06:41:15 -0500 Subject: [PATCH 4/6] Added restricted queue documentation --- docs/explanation/authentication.rst | 5 +++-- docs/explanation/index.rst | 1 + docs/how-to/authentication.rst | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/explanation/authentication.rst b/docs/explanation/authentication.rst index 80308384..cc5546d8 100644 --- a/docs/explanation/authentication.rst +++ b/docs/explanation/authentication.rst @@ -3,7 +3,8 @@ Authentication and Authorisation Authentication requires a client_id and a secret_key. These credentials can be obtained by contacting the server administrator with the queues you want priority -access for as well as the maximum priority level to set for each queue. The -expectation is that these credentials are shared between users on a team. +access for, the maximum priority level to set for each queue, and any restricted +queues that you need access to. The expectation is that these credentials are +shared between users on a team. These credentials can be :doc:`set using the Testflinger CLI <../how-to/authentication>`. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst index c4cc421a..9a1e2d57 100644 --- a/docs/explanation/index.rst +++ b/docs/explanation/index.rst @@ -9,4 +9,5 @@ This section covers conceptual questions about Testflinger. agents queues job-priority + restricted-queues authentication diff --git a/docs/how-to/authentication.rst b/docs/how-to/authentication.rst index f95aa6d2..0f957706 100644 --- a/docs/how-to/authentication.rst +++ b/docs/how-to/authentication.rst @@ -1,7 +1,7 @@ Authentication using Testflinger CLI ==================================== -:doc:`Authentication <../explanation/authentication>` is only required for submitting jobs with priority. +:doc:`Authentication <../explanation/authentication>` is only required for submitting jobs with priority or submitting jobs to a restricted queue. Authenticating with Testflinger server requires a client id and a secret key. These credentials can be provided to the CLI using the environment variables From 88e054940578c6fb670be5f69eff2f61ee497f6c Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Fri, 22 Nov 2024 15:54:08 -0600 Subject: [PATCH 5/6] Refactor token checking --- docs/explanation/authentication.rst | 3 +- docs/explanation/restricted-queues.rst | 9 +++++ server/src/api/v1.py | 53 ++++++++++++++++++++------ 3 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 docs/explanation/restricted-queues.rst diff --git a/docs/explanation/authentication.rst b/docs/explanation/authentication.rst index cc5546d8..4b645743 100644 --- a/docs/explanation/authentication.rst +++ b/docs/explanation/authentication.rst @@ -4,7 +4,6 @@ Authentication and Authorisation Authentication requires a client_id and a secret_key. These credentials can be obtained by contacting the server administrator with the queues you want priority access for, the maximum priority level to set for each queue, and any restricted -queues that you need access to. The expectation is that these credentials are -shared between users on a team. +queues that you need access to. These credentials can be :doc:`set using the Testflinger CLI <../how-to/authentication>`. diff --git a/docs/explanation/restricted-queues.rst b/docs/explanation/restricted-queues.rst new file mode 100644 index 00000000..531b1f0b --- /dev/null +++ b/docs/explanation/restricted-queues.rst @@ -0,0 +1,9 @@ +Restricted Queues +================= + +Restricted queues are queues that are access controlled. These queues only let +clients with the correct authorisation push jobs to them. This requires +:doc:`authenticating <./authentication>` with Testflinger server and obtaining +a token. + +Contact an administrator if you would like access to a restricted queue or if you would like to create your own restricted queue. diff --git a/server/src/api/v1.py b/server/src/api/v1.py index 9f0b781d..cbde7020 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -105,17 +105,11 @@ def has_attachments(data: dict) -> bool: ) -def check_token_permissions( - auth_token: str, secret_key: str, priority: int, queue: str -) -> bool: +def decode_jwt_token(auth_token: str, secret_key: str) -> dict: """ - Validates token received from client and checks if it can - push a job to the queue with the requested priority + Decodes authorization token using the secret key. Aborts with + an HTTP error if it does not exist or if it fails to decode """ - is_queue_restricted = database.check_queue_restricted(queue) - if not is_queue_restricted and priority == 0: - return True - if auth_token is None: abort(401, "Unauthorized") try: @@ -130,14 +124,51 @@ def check_token_permissions( except jwt.exceptions.InvalidTokenError: abort(403, "Invalid Token") + return decoded_jwt + + +def check_token_priority( + auth_token: str, secret_key: str, queue: str, priority: int +) -> bool: + """ + Checks if the requested priority is less than the max priority + specified in the authorization token if it exists + """ + if priority == 0: + return True + decoded_jwt = decode_jwt_token(auth_token, secret_key) max_priority_dict = decoded_jwt.get("max_priority", {}) star_priority = max_priority_dict.get("*", 0) queue_priority = max_priority_dict.get(queue, 0) max_priority = max(star_priority, queue_priority) + return priority <= max_priority + + +def check_token_queue(auth_token: str, secret_key: str, queue: str) -> bool: + """ + Checks if the queue is in the restricted list. If it is, then it + checks the authorization token for restricted queues the user is + allowed to use. + """ + if not database.check_queue_restricted(queue): + return True + decoded_jwt = decode_jwt_token(auth_token, secret_key) allowed_queues = decoded_jwt.get("allowed_queues", []) - return max_priority >= priority and ( - not is_queue_restricted or queue in allowed_queues + return queue in allowed_queues + + +def check_token_permissions( + auth_token: str, secret_key: str, priority: int, queue: str +) -> bool: + """ + Validates token received from client and checks if it can + push a job to the queue with the requested priority + """ + priority_allowed = check_token_priority( + auth_token, secret_key, queue, priority ) + queue_allowed = check_token_queue(auth_token, secret_key, queue) + return priority_allowed and queue_allowed def job_builder(data: dict, auth_token: str): From 1f693ba3fe96ee6288676c54aee3df373fc7923e Mon Sep 17 00:00:00 2001 From: Varun Valada Date: Tue, 3 Dec 2024 14:57:42 -0600 Subject: [PATCH 6/6] Add job_priority to the job_data field --- server/src/api/v1.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/api/v1.py b/server/src/api/v1.py index cbde7020..76ca255f 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -211,7 +211,6 @@ def job_builder(data: dict, auth_token: str): ), ) job["job_priority"] = priority_level - data.pop("job_priority", None) job["job_id"] = job_id job["job_data"] = data