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

Restricted Queues #406

Merged
merged 6 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cli/testflinger_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions docs/explanation/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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.

These credentials can be :doc:`set using the Testflinger CLI <../how-to/authentication>`.
1 change: 1 addition & 0 deletions docs/explanation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ This section covers conceptual questions about Testflinger.
agents
queues
job-priority
restricted-queues
plars marked this conversation as resolved.
Show resolved Hide resolved
authentication
9 changes: 9 additions & 0 deletions docs/explanation/restricted-queues.rst
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/how-to/authentication.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
98 changes: 67 additions & 31 deletions server/src/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,10 @@ def has_attachments(data: dict) -> bool:
)


def check_token_priority_permission(
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
"""
if auth_token is None:
abort(401, "Unauthorized")
Expand All @@ -126,11 +124,51 @@ def check_token_priority_permission(
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 max_priority >= 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 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):
Expand All @@ -156,27 +194,24 @@ 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

job["job_id"] = job_id
job["job_data"] = data
return job
Expand Down Expand Up @@ -712,16 +747,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

Expand All @@ -744,8 +781,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")
Expand Down
8 changes: 8 additions & 0 deletions server/src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions server/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading