Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit b4cc89d

Browse files
[feat] Add endpoint that checks whether an owner has Gen AI consent (#1102)
Co-authored-by: Matthew T <[email protected]>
1 parent 9f89174 commit b4cc89d

File tree

6 files changed

+188
-0
lines changed

6 files changed

+188
-0
lines changed

api/gen_ai/__init__.py

Whitespace-only changes.

api/gen_ai/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from rest_framework import serializers
2+
3+
4+
class GenAIAuthSerializer(serializers.Serializer):
5+
is_valid = serializers.BooleanField()

api/gen_ai/tests/test_gen_ai.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import hmac
2+
from hashlib import sha256
3+
from unittest.mock import patch
4+
5+
from django.urls import reverse
6+
from rest_framework import status
7+
from rest_framework.test import APITestCase
8+
from shared.django_apps.core.tests.factories import OwnerFactory
9+
10+
from codecov_auth.models import GithubAppInstallation
11+
12+
PAYLOAD_SECRET = b"testixik8qdauiab1yiffydimvi72ekq"
13+
VIEW_URL = reverse("auth")
14+
15+
16+
def sign_payload(data: bytes, secret=PAYLOAD_SECRET):
17+
signature = "sha256=" + hmac.new(secret, data, digestmod=sha256).hexdigest()
18+
return signature, data
19+
20+
21+
class GenAIAuthViewTests(APITestCase):
22+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
23+
def test_missing_parameters(self, mock_config):
24+
payload = b"{}"
25+
sig, data = sign_payload(payload)
26+
response = self.client.post(
27+
VIEW_URL,
28+
data=data,
29+
content_type="application/json",
30+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
31+
)
32+
self.assertEqual(response.status_code, 400)
33+
self.assertIn("Missing required parameters", response.data)
34+
35+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
36+
def test_invalid_signature(self, mock_config):
37+
# Correct payload
38+
payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
39+
# Wrong signature based on a different payload
40+
wrong_sig = "sha256=" + hmac.new(PAYLOAD_SECRET, b"{}", sha256).hexdigest()
41+
response = self.client.post(
42+
VIEW_URL,
43+
data=payload,
44+
content_type="application/json",
45+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=wrong_sig,
46+
)
47+
self.assertEqual(response.status_code, 403)
48+
49+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
50+
def test_owner_not_found(self, mock_config):
51+
payload = b'{"external_owner_id":"nonexistent_owner","repo_service_id":"101"}'
52+
sig, data = sign_payload(payload)
53+
response = self.client.post(
54+
VIEW_URL,
55+
data=data,
56+
content_type="application/json",
57+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
58+
)
59+
self.assertEqual(response.status_code, 404)
60+
61+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
62+
def test_no_installation(self, mock_config):
63+
# Create a valid owner but no installation
64+
OwnerFactory(service="github", service_id="owner1", username="test1")
65+
payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}'
66+
sig, data = sign_payload(payload)
67+
response = self.client.post(
68+
VIEW_URL,
69+
data=data,
70+
content_type="application/json",
71+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
72+
)
73+
self.assertEqual(response.status_code, 200)
74+
self.assertEqual(response.data, {"is_valid": False})
75+
76+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
77+
def test_authorized(self, mock_config):
78+
owner = OwnerFactory(service="github", service_id="owner2", username="test2")
79+
GithubAppInstallation.objects.create(
80+
installation_id=12345,
81+
owner=owner,
82+
name="ai-features",
83+
repository_service_ids=["101", "202"],
84+
)
85+
payload = b'{"external_owner_id":"owner2","repo_service_id":"101"}'
86+
sig, data = sign_payload(payload)
87+
response = self.client.post(
88+
VIEW_URL,
89+
data=data,
90+
content_type="application/json",
91+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
92+
)
93+
self.assertEqual(response.status_code, status.HTTP_200_OK)
94+
self.assertEqual(response.data, {"is_valid": True})
95+
96+
@patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET)
97+
def test_unauthorized(self, mock_config):
98+
owner = OwnerFactory(service="github", service_id="owner3", username="test3")
99+
GithubAppInstallation.objects.create(
100+
installation_id=2,
101+
owner=owner,
102+
name="ai-features",
103+
repository_service_ids=["303", "404"],
104+
)
105+
payload = b'{"external_owner_id":"owner3","repo_service_id":"101"}'
106+
sig, data = sign_payload(payload)
107+
response = self.client.post(
108+
VIEW_URL,
109+
data=data,
110+
content_type="application/json",
111+
HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig,
112+
)
113+
self.assertEqual(response.status_code, 200)
114+
self.assertEqual(response.data, {"is_valid": False})

api/gen_ai/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.urls import path
2+
3+
from .views import GenAIAuthView
4+
5+
urlpatterns = [
6+
path("auth/", GenAIAuthView.as_view(), name="auth"),
7+
]

api/gen_ai/views.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import hmac
2+
import logging
3+
from hashlib import sha256
4+
5+
from rest_framework.exceptions import NotFound, PermissionDenied
6+
from rest_framework.permissions import AllowAny
7+
from rest_framework.response import Response
8+
from rest_framework.views import APIView
9+
10+
from api.gen_ai.serializers import GenAIAuthSerializer
11+
from codecov_auth.models import GithubAppInstallation, Owner
12+
from graphql_api.types.owner.owner import AI_FEATURES_GH_APP_ID
13+
from utils.config import get_config
14+
15+
log = logging.getLogger(__name__)
16+
17+
18+
class GenAIAuthView(APIView):
19+
permission_classes = [AllowAny]
20+
serializer_class = GenAIAuthSerializer
21+
22+
def validate_signature(self, request):
23+
key = get_config("gen_ai", "auth_secret")
24+
if not key:
25+
raise PermissionDenied("Invalid signature")
26+
27+
if isinstance(key, str):
28+
key = key.encode("utf-8")
29+
expected_sig = request.headers.get("HTTP-X-GEN-AI-AUTH-SIGNATURE")
30+
computed_sig = (
31+
"sha256=" + hmac.new(key, request.body, digestmod=sha256).hexdigest()
32+
)
33+
if not hmac.compare_digest(computed_sig, expected_sig):
34+
raise PermissionDenied("Invalid signature")
35+
36+
def post(self, request, *args, **kwargs):
37+
self.validate_signature(request)
38+
external_owner_id = request.data.get("external_owner_id")
39+
repo_service_id = request.data.get("repo_service_id")
40+
if not external_owner_id or not repo_service_id:
41+
return Response("Missing required parameters", status=400)
42+
try:
43+
owner = Owner.objects.get(service_id=external_owner_id)
44+
except Owner.DoesNotExist:
45+
raise NotFound("Owner not found")
46+
47+
is_authorized = True
48+
49+
app_install = GithubAppInstallation.objects.filter(
50+
owner_id=owner.ownerid, app_id=AI_FEATURES_GH_APP_ID
51+
).first()
52+
53+
if not app_install:
54+
is_authorized = False
55+
56+
else:
57+
repo_ids = app_install.repository_service_ids
58+
if repo_ids and repo_service_id not in repo_ids:
59+
is_authorized = False
60+
61+
return Response({"is_valid": is_authorized})

codecov/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,5 @@
3737
# /monitoring/metrics will be a public route unless you take steps at a
3838
# higher level to null-route or redirect it.
3939
path("monitoring/", include("django_prometheus.urls")),
40+
path("gen_ai/", include("api.gen_ai.urls")),
4041
]

0 commit comments

Comments
 (0)