Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d191b0e
add retrieve job migration
paaragon Sep 4, 2025
ca7c3c2
add save job result migration
paaragon Sep 4, 2025
d6a5a9b
add save job result migration
paaragon Sep 4, 2025
774ac7e
add get logs migration
paaragon Sep 4, 2025
abaa5f8
add get logs migration
paaragon Sep 4, 2025
b5b3f04
fix error propagations
paaragon Sep 4, 2025
424408a
migrate job.stop method
paaragon Sep 9, 2025
b8b2852
restore runtime endpoints
paaragon Sep 10, 2025
aafdf28
Merge branch 'main' into feat-migrate-all-jobs-endpoints
paaragon Sep 10, 2025
9c205ee
add openai to new endpoints
paaragon Sep 10, 2025
1833cab
Merge branch 'feat-migrate-all-jobs-endpoints' of github.com:Qiskit/q…
paaragon Sep 10, 2025
ba54c10
wip
paaragon Sep 11, 2025
5a1f9c3
refactor list jobs
paaragon Sep 11, 2025
15ba639
refactor retrieve jobs
paaragon Sep 11, 2025
2dbbfdd
refactor retrieve jobs
paaragon Sep 11, 2025
236b2d2
refactor retrieve jobs
paaragon Sep 11, 2025
b5a1a4d
refactor get logs usecase
paaragon Sep 11, 2025
254c82b
refactor provider list jobs usecase
paaragon Sep 11, 2025
a480a16
refactor provider list jobs usecase
paaragon Sep 11, 2025
e3e7b4b
refactor provider list jobs usecase
paaragon Sep 11, 2025
98d2118
refactor set_sub_status
paaragon Sep 11, 2025
d469e7c
apply review suggestions
paaragon Sep 15, 2025
c4aea96
apply review suggestions
paaragon Sep 15, 2025
d030b97
apply review suggestions
paaragon Sep 16, 2025
f331bc9
apply review suggestions
paaragon Sep 18, 2025
3ee945d
Merge branch 'feat-migrate-all-jobs-endpoints' into feat-log-consent
paaragon Sep 18, 2025
a0b071a
add consent
paaragon Sep 18, 2025
761a801
add consent test
paaragon Sep 18, 2025
b76b55a
apply review suggestions
paaragon Sep 18, 2025
e9a3743
Merge branch 'main' into feat-migrate-all-jobs-endpoints
paaragon Sep 18, 2025
b79fdb2
Merge branch 'main' into feat-migrate-all-jobs-endpoints
paaragon Sep 22, 2025
327879c
Merge branch 'main' into feat-log-consent
paaragon Sep 22, 2025
6f7c38f
update get_logs to retrict access
paaragon Sep 22, 2025
d9dfb51
add warning when getting function by title
paaragon Sep 22, 2025
87ad3ed
add warning when getting function by title
paaragon Sep 22, 2025
e304eaf
Merge branch 'feat-migrate-all-jobs-endpoints' into feat-log-consent
paaragon Sep 23, 2025
5e56554
fix programserializerwithconsent
paaragon Sep 23, 2025
ecfc06e
add function access policy
paaragon Sep 25, 2025
8db1438
fix consent tests
paaragon Sep 26, 2025
24ad1a8
Merge branch 'main' into feat-log-consent
paaragon Sep 29, 2025
693fd56
fix merge
paaragon Oct 2, 2025
ed17af3
Merge branch 'main' into feat-log-consent
korgan00 Oct 8, 2025
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
33 changes: 33 additions & 0 deletions client/qiskit_serverless/core/clients/serverless_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from pathlib import Path
from dataclasses import asdict
from typing import Optional, List, Dict, Any, Union
import warnings

import requests
from opentelemetry import trace
Expand Down Expand Up @@ -528,6 +529,38 @@ def function(
)
)

response_warning = response_data["warning"]
if response_warning:
warnings.warn(response_warning, Warning)

response_data["client"] = self
the_function = RunnableQiskitFunction.from_json(response_data)
return the_function

@_trace_functions("submit_logs_terms_and_conditions")
def submit_logs_terms_and_conditions(
self, title: str, provider: str
) -> Optional[RunnableQiskitFunction]:
"""Returns program based on parameters."""
provider, title = format_provider_name_and_title(
request_provider=provider, title=title
)

response_data = safe_json_request_as_dict(
request=lambda: requests.get(
f"{self.host}/api/{self.version}/programs/{title}/consent/",
headers=get_headers(
token=self.token, instance=self.instance, channel=self.channel
),
params={"provider": provider},
timeout=REQUESTS_TIMEOUT,
)
)

response_warning = response_data["warning"]
if response_warning:
warnings.warn(response_warning, Warning)

response_data["client"] = self
the_function = RunnableQiskitFunction.from_json(response_data)
return the_function
Expand Down
56 changes: 56 additions & 0 deletions gateway/api/access_policies/functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
Access policy functions for determining user permissions on functions.
"""
from enum import Enum, auto
from typing import Tuple, Optional

from django.contrib.auth.models import AbstractUser

from api.models import Program
from api.repositories.functions import FunctionRepository


class RunReason(Enum):
"""Reasons why a user cannot execute a function."""

FUNCTION_DISABLED = auto()
CONSENT_NOT_ACCEPTED = auto()


class FunctionAccessPolicy:
"""
Policy class for functions.
"""

function_repository = FunctionRepository()

@staticmethod
def can_run(
user: AbstractUser, function: Program
) -> Tuple[bool, Optional[RunReason]]:
"""
Determines if a user can run a specific function.

Args:
user: The user attempting to run the function
function: The function (program) being attempted to run

Returns:
A tuple containing:
- A boolean indicating if the user can run the function
- Optionally, the reason why they cannot run it (if applicable)
"""
if function.disabled:
return False, RunReason.FUNCTION_DISABLED

if user == function.author:
return True, None

log_consent = FunctionAccessPolicy.function_repository.get_log_consent(
user, function
)

if not log_consent or not log_consent.accepted:
return False, RunReason.CONSENT_NOT_ACCEPTED

return True, None
30 changes: 30 additions & 0 deletions gateway/api/access_policies/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from api.models import Job
from api.access_policies.providers import ProviderAccessPolicy
from api.repositories.functions import FunctionRepository


logger = logging.getLogger("gateway")
Expand Down Expand Up @@ -111,3 +112,32 @@ def can_update_sub_status(user: type[AbstractUser], job: Job) -> bool:
job.id,
)
return has_access

@staticmethod
def can_read_logs(user: type[AbstractUser], job: Job) -> bool:
"""
Checks if the user has permissions to read the logs of a job.

Access is granted if:
- The user is the author of the job
- The user has access to the provider and has consent to view logs for the function

Args:
user: Django user from the request
job: Job instance against which to check the permission

Returns:
bool: True if the user has permission to read logs, False otherwise
"""
if user.id == job.author.id:
return True

if not job.program.provider or not ProviderAccessPolicy.can_access(
user, job.program.provider
):
return False

function_repository = FunctionRepository()
consent = function_repository.get_log_consent(user, job.function)

return consent is not None and consent.accepted
49 changes: 49 additions & 0 deletions gateway/api/migrations/0039_logconsent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 5.2.6 on 2025-09-18 10:40

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("api", "0038_program_disabled_program_disabled_message"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="LogConsent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("accepted", models.BooleanField()),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True, null=True)),
(
"function",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.program"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("user", "function")},
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.2.6 on 2025-09-24 08:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("api", "0039_logconsent"),
]

operations = [
migrations.AlterUniqueTogether(
name="logconsent",
unique_together=set(),
),
migrations.AddField(
model_name="program",
name="eula_link",
field=models.CharField(blank=True, null=True),
),
]
22 changes: 22 additions & 0 deletions gateway/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ class Program(ExportModelOperationsMixin("program"), models.Model):
null=True,
blank=True,
)
eula_link = models.CharField(null=True)

class Meta:
permissions = ((RUN_PROGRAM_PERMISSION, "Can run function"),)
Expand All @@ -136,6 +137,27 @@ def __str__(self):
return f"{self.title}"


class LogConsent(models.Model):
"""
Model to track user consent for logging function.
"""

user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
function = models.ForeignKey(Program, on_delete=models.CASCADE)
accepted = models.BooleanField()
created = models.DateTimeField(auto_now_add=True, editable=False)
updated = models.DateTimeField(auto_now=True, null=True)

def __str__(self):
"""Return a string representation of the LogConsent object."""
status = "accepted" if self.accepted else "rejected"
return f"Consent {status} by {self.user} for {self.function}"

def is_valid(self):
"""Check if the consent is valid and accepted."""
return self.accepted


class ComputeResource(models.Model):
"""Compute resource model."""

Expand Down
32 changes: 29 additions & 3 deletions gateway/api/repositories/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

import logging
from typing import List, Optional
from django.contrib.auth.models import AbstractUser
from django.db.models import Q
from django.contrib.auth.models import Group

from api.models import Program as Function
from api.models import LogConsent, Program as Function
from api.repositories.users import UserRepository


Expand Down Expand Up @@ -195,7 +196,7 @@ def get_function_by_permission(
user,
permission_name: str,
function_title: str,
provider_name: Optional[str],
provider_name: Optional[str] = None,
) -> Optional[Function]:
"""
This method returns the specified function if the user is
Expand Down Expand Up @@ -225,7 +226,7 @@ def get_function_by_permission(
def get_function(
self,
function_title: str,
provider_name: Optional[str],
provider_name: Optional[str] = None,
) -> Optional[Function]:
"""
This method returns the specified function unconditionally.
Expand Down Expand Up @@ -267,3 +268,28 @@ def get_trial_instances(self, function: Function) -> List[Group]:
[Group]: list of available groups
"""
return function.trial_instances.all()

def add_log_consent_to_function(
self, user: AbstractUser, function: Function, accepted: bool
):
"""
Add or update the log consent for an user and function
"""
consent, _ = LogConsent.objects.update_or_create(
user=user,
function=function,
defaults={"accepted": accepted},
)

return consent

def get_log_consent(
self, user: AbstractUser, function: Function
) -> Optional[LogConsent]:
"""
Returns the log consent for an user and function
"""
try:
return LogConsent.objects.get(user=user, function=function)
except LogConsent.DoesNotExist:
return None
27 changes: 27 additions & 0 deletions gateway/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from api.repositories.users import UserRepository
from api.utils import build_env_variables, encrypt_env_vars, sanitize_name
from .models import (
LogConsent,
Provider,
Program,
Job,
Expand Down Expand Up @@ -200,6 +201,32 @@ class Meta:
model = Program


class ProgramSerializerWithConsent(serializers.ModelSerializer):
"""
Program serializer that includes user consent information.
"""

user_consent = serializers.SerializerMethodField()
provider = serializers.CharField(source="provider.name", read_only=True)

class Meta:
model = Program
fields = "__all__"

def get_user_consent(self, obj):
"""
Get the user consent for the program.
Returns True if the user has accepted, False if rejected, None if not set.
"""
user = self.context.get("user")
if not user or user.is_anonymous:
return None

consent = LogConsent.objects.filter(user=user, function=obj).first()

return consent.accepted if consent is not None else None


class JobSerializer(serializers.ModelSerializer):
"""
Serializer for the job model.
Expand Down
Loading
Loading