diff --git a/device-connectors/src/testflinger_device_connectors/devices/__init__.py b/device-connectors/src/testflinger_device_connectors/devices/__init__.py index f8d897b6..1fb7f940 100644 --- a/device-connectors/src/testflinger_device_connectors/devices/__init__.py +++ b/device-connectors/src/testflinger_device_connectors/devices/__init__.py @@ -276,12 +276,6 @@ def reserve(self, args): logger.error("Failed to copy ssh key: %s", key) # default reservation timeout is 1 hour timeout = int(reserve_data.get("timeout", "3600")) - # If max_reserve_timeout isn't specified, default to 6 hours - max_reserve_timeout = int( - config.get("max_reserve_timeout", 6 * 60 * 60) - ) - if timeout > max_reserve_timeout: - timeout = max_reserve_timeout serial_host = config.get("serial_host") serial_port = config.get("serial_port") print("*** TESTFLINGER SYSTEM RESERVED ***") diff --git a/docs/explanation/extended-reservation.rst b/docs/explanation/extended-reservation.rst new file mode 100644 index 00000000..d4116ad0 --- /dev/null +++ b/docs/explanation/extended-reservation.rst @@ -0,0 +1,10 @@ +Extended Reservation +============ + +Normal users can reserve a machine with Testflinger for a maximum of 6 hours. +However, for certain use cases this is limiting. Testflinger has the ability +to allow authorised clients to request longer reservation times. See the Reserve +section of the :doc:`Test Phase <../reference/test-phases>` documentation for more +information on how to set reservation times. +Using this feature requires :doc:`authenticating <./authentication>` with +Testflinger server. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst index 9a1e2d57..54189392 100644 --- a/docs/explanation/index.rst +++ b/docs/explanation/index.rst @@ -10,4 +10,5 @@ This section covers conceptual questions about Testflinger. queues job-priority restricted-queues + extended-reservation authentication diff --git a/docs/reference/test-phases.rst b/docs/reference/test-phases.rst index 3ef962fe..07c8ac85 100644 --- a/docs/reference/test-phases.rst +++ b/docs/reference/test-phases.rst @@ -178,6 +178,7 @@ Variables in ``reserve_data``: * ``ssh_keys``: The list of public SSH keys to use for reserving the device. Each line includes an identity provider name and your username on the provider's system. Testflinger uses the ``ssh-import-id`` command to import public SSH keys from trusted, online identity. Supported identities are Launchpad (``lp``) and GitHub (``gh``). * ``timeout``: Reservation time in seconds. The default is one hour (3600), and you can request a reservation for up to 6 hours (21600). + Authenticated clients can request longer :doc:`reservation times <../explanation/extended-reservation>` with prior authorisation. If either ``reserve_command`` is missing from the agent configuration, or the the ``reserve_data`` section is missing from the job, this phase will be skipped. diff --git a/server/src/api/v1.py b/server/src/api/v1.py index cad09bab..e5735d96 100644 --- a/server/src/api/v1.py +++ b/server/src/api/v1.py @@ -135,40 +135,91 @@ def check_token_priority( specified in the authorization token if it exists """ if priority == 0: - return True + return decoded_jwt = decode_jwt_token(auth_token, secret_key) - max_priority_dict = decoded_jwt.get("max_priority", {}) + permissions = decoded_jwt.get("permissions", {}) + max_priority_dict = permissions.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 + if priority > max_priority: + abort( + 403, + ( + f"Not enough permissions to push to {queue}", + f"with priority {priority}", + ), + ) -def check_token_queue(auth_token: str, secret_key: str, queue: str) -> bool: +def check_token_queue(auth_token: str, secret_key: str, queue: str): """ 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 + return decoded_jwt = decode_jwt_token(auth_token, secret_key) - allowed_queues = decoded_jwt.get("allowed_queues", []) - return queue in allowed_queues + permissions = decoded_jwt.get("permissions", {}) + allowed_queues = permissions.get("allowed_queues", []) + if queue not in allowed_queues: + abort( + 403, + ( + "Not enough permissions to push to the ", + f"restricted queue: {queue}", + ), + ) + + +def check_token_reservation_timeout( + auth_token: str, secret_key: str, reservation_timeout: int, queue: str +): + """ + Checks if the requested reservation is either less than the max + or that their token gives them the permission to use a higher one + """ + # Max reservation time defaults to 6 hours + max_reservation_time = 6 * 60 * 60 + if reservation_timeout <= max_reservation_time: + return + decoded_jwt = decode_jwt_token(auth_token, secret_key) + permissions = decoded_jwt.get("permissions", {}) + max_reservation_time_dict = permissions.get("max_reservation_time", {}) + queue_reservation_time = max_reservation_time_dict.get(queue, 0) + star_reservation_time = max_reservation_time_dict.get("*", 0) + max_reservation_time = max(queue_reservation_time, star_reservation_time) + if reservation_timeout > max_reservation_time: + abort( + 403, + ( + f"Not enough permissions to push to {queue}", + f"with reservation timeout {reservation_timeout}", + ), + ) def check_token_permissions( - auth_token: str, secret_key: str, priority: int, queue: str -) -> bool: + auth_token: str, + secret_key: str, + job_data: dict, +): """ 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 + priority_level = job_data.get("job_priority", 0) + job_queue = job_data["job_queue"] + check_token_priority(auth_token, secret_key, job_queue, priority_level) + check_token_queue(auth_token, secret_key, job_queue) + + reserve_data = job_data.get("reserve_data", {}) + # default reservation timeout is 1 hour + reservation_timeout = reserve_data.get("timeout", 3600) + check_token_reservation_timeout( + auth_token, secret_key, reservation_timeout, job_queue ) - queue_allowed = check_token_queue(auth_token, secret_key, queue) - return priority_allowed and queue_allowed def job_builder(data: dict, auth_token: str): @@ -195,21 +246,11 @@ def job_builder(data: dict, auth_token: str): data["attachments_status"] = "waiting" priority_level = data.get("job_priority", 0) - job_queue = data["job_queue"] - allowed = check_token_permissions( + check_token_permissions( auth_token, os.environ.get("JWT_SIGNING_KEY"), - priority_level, - job_queue, + data, ) - if not allowed: - abort( - 403, - ( - f"Not enough permissions to push to {job_queue}", - f"with priority {priority_level}", - ), - ) job["job_priority"] = priority_level job["job_id"] = job_id @@ -755,17 +796,18 @@ def get_agents_on_queue(queue_name): def generate_token(allowed_resources, secret_key): - """Generates JWT token with queue permission given a secret key""" + """ + Generates JWT token with queue permission given a secret key + See retrieve_token for more information on the contents of + the token payload + """ expiration_time = datetime.utcnow() + timedelta(seconds=2) token_payload = { "exp": expiration_time, "iat": datetime.now(timezone.utc), # Issued at time "sub": "access_token", + "permissions": allowed_resources, } - 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 @@ -788,12 +830,28 @@ def validate_client_key_pair(client_id: str, client_key: str): client_permissions_entry["client_secret_hash"].encode("utf8"), ): return None + client_permissions_entry.pop("_id", None) + client_permissions_entry.pop("client_secret_hash", None) return client_permissions_entry @v1.post("/oauth2/token") def retrieve_token(): - """Get JWT with priority and queue permissions""" + """ + Get JWT with priority and queue permissions + + Before being encrypted, the JWT can contain fields like: + { + exp: , + iat: , + sub: , + permissions: { + max_priority: , + allowed_queues: , + max_reservation_time: , + } + } + """ auth_header = request.authorization if auth_header is None: return "No authorization header specified", 401 diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 849ec9a3..0a2f5898 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -84,12 +84,14 @@ def mongo_app_with_permissions(mongo_app): "myqueue2": 200, } allowed_queues = ["rqueue1", "rqueue2"] + max_reservation_time = {"myqueue": 30000} mongo.client_permissions.insert_one( { "client_id": client_id, "client_secret_hash": client_key_hash, "max_priority": max_priority, "allowed_queues": allowed_queues, + "max_reservation_time": max_reservation_time, } ) restricted_queues = [ diff --git a/server/tests/test_v1_authorization.py b/server/tests/test_v1_authorization.py index b05ebd08..bdf6b9a3 100644 --- a/server/tests/test_v1_authorization.py +++ b/server/tests/test_v1_authorization.py @@ -50,9 +50,9 @@ def test_retrieve_token(mongo_app_with_permissions): token, os.environ.get("JWT_SIGNING_KEY"), algorithms="HS256", - options={"require": ["exp", "iat", "sub", "max_priority"]}, + options={"require": ["exp", "iat", "sub", "permissions"]}, ) - assert decoded_token["max_priority"] == max_priority + assert decoded_token["permissions"]["max_priority"] == max_priority def test_retrieve_token_invalid_client_id(mongo_app_with_permissions): @@ -147,7 +147,9 @@ def test_priority_expired_token(mongo_app_with_permissions): "exp": datetime.utcnow() - timedelta(seconds=2), "iat": datetime.utcnow() - timedelta(seconds=4), "sub": "access_token", - "max_priority": {}, + "permissions": { + "max_priority": {}, + }, } token = jwt.encode(expired_token_payload, secret_key, algorithm="HS256") job = {"job_queue": "myqueue", "job_priority": 100} @@ -163,7 +165,9 @@ def test_missing_fields_in_token(mongo_app_with_permissions): app, _, _, _, _ = mongo_app_with_permissions secret_key = os.environ.get("JWT_SIGNING_KEY") incomplete_token_payload = { - "max_priority": {}, + "permissions": { + "max_priority": {}, + } } token = jwt.encode(incomplete_token_payload, secret_key, algorithm="HS256") job = {"job_queue": "myqueue", "job_priority": 100} @@ -316,3 +320,83 @@ def test_restricted_queue_reject_no_token(mongo_app_with_permissions): job = {"job_queue": "rqueue1"} job_response = app.post("/v1/job", json=job) assert 401 == job_response.status_code + + +def test_extended_reservation_allowed(mongo_app_with_permissions): + """ + Tests that jobs that include extended reservation are accepted when + the token gives them permission + """ + 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": "myqueue", "reserve_data": {"timeout": 30000}} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 200 == job_response.status_code + + +def test_extended_reservation_rejected(mongo_app_with_permissions): + """ + Tests that jobs that include extended reservation are rejected when + the token does not give them permission + """ + 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", "reserve_data": {"timeout": 21601}} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 403 == job_response.status_code + + +def test_extended_reservation_reject_no_token(mongo_app_with_permissions): + """ + Tests that jobs that included extended reservation are rejected + when no token is included + """ + app, _, _, _, _ = mongo_app_with_permissions + job = {"job_queue": "myqueue", "reserve_data": {"timeout": 21601}} + job_response = app.post("/v1/job", json=job) + assert 401 == job_response.status_code + + +def test_normal_reservation_no_token(mongo_app): + """ + Tests that jobs that include reservation times less than the maximum + are accepted when no token is included + """ + app, _ = mongo_app + job = {"job_queue": "myqueue", "reserve_data": {"timeout": 21600}} + job_response = app.post("/v1/job", json=job) + assert 200 == job_response.status_code + + +def test_star_extended_reservation(mongo_app_with_permissions): + """ + Tests submission to generic queue with extended reservation + when client has star permissions + """ + app, mongo, client_id, client_key, _ = mongo_app_with_permissions + mongo.client_permissions.find_one_and_update( + {"client_id": client_id}, + {"$set": {"max_reservation_time": {"*": 30000}}}, + ) + 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": "myrandomqueue", "reserve_data": {"timeout": 30000}} + job_response = app.post( + "/v1/job", json=job, headers={"Authorization": token} + ) + assert 200 == job_response.status_code