Skip to content

Commit

Permalink
Merge pull request #406 from canonical/CERTTF-419-Restricted-queues
Browse files Browse the repository at this point in the history
Restricted Queues
  • Loading branch information
val500 authored Dec 3, 2024
2 parents 79ba119 + 1f693ba commit cec3c40
Show file tree
Hide file tree
Showing 10 changed files with 422 additions and 285 deletions.
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
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

0 comments on commit cec3c40

Please sign in to comment.