Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ def create_jobs(self):
updated_job = self.inject_parent_jobid(updated_job)

try:
job_id = self.client.submit_job(updated_job)
# Use agent job submission for credential inheritance
job_id = self.client.submit_agent_job(updated_job)
except requests.exceptions.HTTPError as exc:
logger.error("Unable to create job: %s", exc.response.text)
self.cancel_jobs(self.jobs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def submit_job(self, job_data):
"""Return a fake job id."""
return str(uuid4())

def submit_agent_job(self, job_data):
"""Return a fake agent job id."""
return self.submit_job(job_data)


def test_bad_tfclient_url():
"""Test that Multi raises an exception when TFClient URL is bad."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,19 @@ def submit_job(self, job_data):
response = self.post(endpoint, job_data)
return json.loads(response).get("job_id")

def submit_agent_job(self, job_data):
"""Submit a child job to the testflinger server with
credential inheritance.

:param job_data:
dict of data for the job to submit, must include parent_job_id
:return:
ID for the test job
"""
endpoint = "/v1/agent/jobs"
response = self.post(endpoint, job_data)
return json.loads(response).get("job_id")

def cancel_job(self, job_id):
"""Tell the server to cancel a specified job_id."""
try:
Expand Down
70 changes: 67 additions & 3 deletions server/src/testflinger/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,47 @@ def validate_secrets(data: dict):
data["client_id"] = client_id


@v1.post("/agent/jobs")
@authenticate
@v1.input(schemas.Job, location="json")
@v1.output(schemas.JobId)
def agent_job_post(json_data: dict):
"""
Add a child job to the queue with inherited credentials
from parent job.
"""
try:
job_queue = json_data.get("job_queue")
parent_job_id = json_data.get("parent_job_id")
except (AttributeError, BadRequest):
job_queue = ""
parent_job_id = None

if not job_queue:
abort(422, message="Invalid data or no job_queue specified")

if not parent_job_id:
abort(422, message="parent_job_id required for agent job submission")

if not check_valid_uuid(parent_job_id):
abort(400, message="Invalid parent_job_id specified")

# Retrieve parent job permissions for credential inheritance
inherited_permissions = database.retrieve_parent_permissions(parent_job_id)

try:
job = job_builder(json_data, inherited_permissions)
except ValueError:
abort(400, message="Invalid job_id specified")

jobs_metric.labels(queue=job_queue).inc()
if "reserve_data" in json_data:
reservations_metric.labels(queue=job_queue).inc()

database.add_job(job)
return jsonify(job_id=job.get("job_id"))


def has_attachments(data: dict) -> bool:
"""Predicate if the job described by `data` involves attachments."""
return any(
Expand All @@ -150,8 +191,13 @@ def has_attachments(data: dict) -> bool:
)


def job_builder(data: dict):
"""Build a job from a dictionary of data."""
def job_builder(data: dict, inherited_permissions: dict = None):
"""Build a job from a dictionary of data.

:param data: Job data dictionary
:param inherited_permissions: Optional permissions inherited from
parent job
"""
job = {
"created_at": datetime.now(timezone.utc),
"result_data": {
Expand All @@ -174,12 +220,30 @@ def job_builder(data: dict):
data["attachments_status"] = "waiting"

priority_level = data.get("job_priority", 0)

# Use inherited permissions if provided, otherwise use current
# user's permissions
if inherited_permissions:
permissions_to_check = inherited_permissions
else:
permissions_to_check = g.permissions
auth.check_permissions(
g.permissions,
permissions_to_check,
data,
)
job["job_priority"] = priority_level

# Store authentication permissions for credential inheritance
if inherited_permissions:
job["auth_permissions"] = inherited_permissions
elif g.is_authenticated:
job["auth_permissions"] = g.permissions

# Store parent job relationship if this is a child job
parent_job_id = data.get("parent_job_id")
if parent_job_id:
job["parent_job_id"] = parent_job_id

job["job_id"] = job_id
job["job_data"] = data
return job
Expand Down
20 changes: 18 additions & 2 deletions server/src/testflinger/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,8 +441,7 @@ def check_client_exists(client_id: str) -> bool:

def add_client_permissions(data: dict) -> None:
"""Create a new client_id along with its permissions.

:param data: client_id along with its permissions
:param data: client_id along with its permissions.
"""
mongo.db.client_permissions.insert_one(data)

Expand Down Expand Up @@ -540,3 +539,20 @@ def delete_refresh_token(token: str) -> None:
:param token: The refresh token to delete.
"""
mongo.db.refresh_tokens.delete_one({"refresh_token": token})


def retrieve_parent_permissions(parent_job_id: str) -> dict:
"""Retrieve auth permissions from parent job for credential inheritance.

:param parent_job_id: UUID of the parent job to inherit permissions from.
:return: Dictionary with parent job's auth permissions,
or empty dict if none.
"""
parent_job = mongo.db.jobs.find_one(
{"job_id": parent_job_id}, {"auth_permissions": True, "_id": False}
)

if not parent_job:
return {}

return parent_job.get("auth_permissions", {})
124 changes: 124 additions & 0 deletions server/tests/test_v1_authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,3 +1232,127 @@ def test_refresh_token_last_accessed_update(mongo_app_with_permissions):
last_accessed_after = token_entry_after["last_accessed"]

assert last_accessed_after > last_accessed_before


def test_retrieve_parent_permissions_valid(mongo_app):
"""Test retrieving auth permissions from valid parent job."""
from testflinger import database

app, mongo = mongo_app
parent_permissions = {
"client_id": "test_client",
"max_priority": {"queue1": 100},
"allowed_queues": ["restricted_queue"],
"max_reservation_time": {"queue1": 7200},
}

# Insert parent job with permissions
parent_job = {
"job_id": "parent-123",
"auth_permissions": parent_permissions,
"job_data": {"job_queue": "queue1"},
}
mongo.jobs.insert_one(parent_job)

# Test retrieval
retrieved_permissions = database.retrieve_parent_permissions("parent-123")
assert retrieved_permissions == parent_permissions


def test_retrieve_parent_permissions_no_permissions(mongo_app):
"""Test retrieving permissions from parent job with no auth_permissions."""
from testflinger import database

app, mongo = mongo_app

# Insert parent job without permissions
parent_job = {"job_id": "parent-456", "job_data": {"job_queue": "queue1"}}
mongo.jobs.insert_one(parent_job)

# Should return empty dict, not error
retrieved_permissions = database.retrieve_parent_permissions("parent-456")
assert retrieved_permissions == {}


def test_retrieve_parent_permissions_nonexistent(mongo_app):
"""Test retrieving permissions from non-existent parent job."""
from testflinger import database

app, mongo = mongo_app

# Should return empty dict, not error
retrieved_permissions = database.retrieve_parent_permissions(
"nonexistent-job"
)
assert retrieved_permissions == {}


def test_agent_jobs_endpoint_with_credentials(mongo_app_with_permissions):
"""Test agent endpoint submits child job with inherited credentials."""
app, mongo, client_id, client_key, _ = mongo_app_with_permissions

# Create parent job with permissions
token = get_access_token(app, client_id, client_key)
parent_job = {"job_queue": "myqueue2", "job_priority": 200}
parent_response = app.post(
"/v1/job", json=parent_job, headers={"Authorization": token}
)
parent_job_id = parent_response.json.get("job_id")

# Submit child job via agent endpoint
child_job = {
"job_queue": "myqueue2",
"job_priority": 200,
"parent_job_id": parent_job_id,
}
child_response = app.post("/v1/agent/jobs", json=child_job)
assert child_response.status_code == 200

child_job_id = child_response.json.get("job_id")
assert child_job_id is not None

# Verify child job inherited parent permissions
child_job_data = mongo.jobs.find_one({"job_id": child_job_id})
assert child_job_data["parent_job_id"] == parent_job_id
assert "auth_permissions" in child_job_data


def test_agent_jobs_endpoint_missing_parent_job_id(mongo_app):
"""Test agent endpoint rejects jobs without parent_job_id."""
app, _ = mongo_app

child_job = {"job_queue": "myqueue"}
response = app.post("/v1/agent/jobs", json=child_job)
assert response.status_code == 422
assert "parent_job_id required" in response.get_json()["message"]


def test_agent_jobs_endpoint_invalid_parent_job_id(mongo_app):
"""Test agent endpoint rejects jobs with invalid parent_job_id."""
app, _ = mongo_app

child_job = {"job_queue": "myqueue", "parent_job_id": "invalid-uuid"}
response = app.post("/v1/agent/jobs", json=child_job)
assert response.status_code == 400
assert "Invalid parent_job_id" in response.get_json()["message"]


def test_agent_jobs_endpoint_no_parent_permissions(mongo_app):
"""Test agent endpoint works when parent has no permissions."""
app, mongo = mongo_app

# Create parent job without authentication (no permissions)
parent_job = {"job_queue": "myqueue"}
parent_response = app.post("/v1/job", json=parent_job)
parent_job_id = parent_response.json.get("job_id")

# Submit child job via agent endpoint
child_job = {"job_queue": "myqueue", "parent_job_id": parent_job_id}
child_response = app.post("/v1/agent/jobs", json=child_job)
assert child_response.status_code == 200

# Verify child job was created successfully
child_job_id = child_response.json.get("job_id")
child_job_data = mongo.jobs.find_one({"job_id": child_job_id})
assert child_job_data["parent_job_id"] == parent_job_id
assert "auth_permissions" not in child_job_data