Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add extended reservation checks in the server #428

Merged
merged 10 commits into from
Jan 24, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -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 ***")
Expand Down
10 changes: 10 additions & 0 deletions docs/explanation/extended-reservation.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/explanation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ This section covers conceptual questions about Testflinger.
queues
job-priority
restricted-queues
extended-reservation
authentication
1 change: 1 addition & 0 deletions docs/reference/test-phases.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
120 changes: 89 additions & 31 deletions server/src/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
boukeas marked this conversation as resolved.
Show resolved Hide resolved
if reservation_timeout <= max_reservation_time:
return
decoded_jwt = decode_jwt_token(auth_token, secret_key)
boukeas marked this conversation as resolved.
Show resolved Hide resolved
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)
boukeas marked this conversation as resolved.
Show resolved Hide resolved
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):
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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: <Expiration DateTime of Token>,
iat: <Issuance DateTime of Token>,
sub: <Subject Field of Token>,
permissions: {
max_priority: <Queue to Priority Level Dict>,
allowed_queues: <List of Allowed Restricted Queues>,
max_reservation_time: <Queue to Max Reservation Time Dict>,
}
}
"""
auth_header = request.authorization
if auth_header is None:
return "No authorization header specified", 401
Expand Down
2 changes: 2 additions & 0 deletions server/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
92 changes: 88 additions & 4 deletions server/tests/test_v1_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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
Loading