Skip to content

Commit 751df6a

Browse files
committed
chore(oauth): use oauth to unify service-to-service authentication.
1 parent 6396165 commit 751df6a

16 files changed

Lines changed: 233 additions & 262 deletions

File tree

charts/controller/templates/_helpers.tpl

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,6 @@ env:
4949
secretKeyRef:
5050
name: controller-creds
5151
key: django-secret-key
52-
- name: DRYCC_SERVICE_KEY
53-
valueFrom:
54-
secretKeyRef:
55-
name: controller-creds
56-
key: service-key
5752
{{- if (.Values.valkeyUrl) }}
5853
- name: DRYCC_VALKEY_URL
5954
valueFrom:
@@ -99,12 +94,6 @@ env:
9994
value: "postgres://$(DRYCC_PG_USER):$(DRYCC_PG_PASSWORD)@drycc-database-replica:5432/controller"
10095
{{- end }}
10196
{{- if (.Values.workflowManagerUrl) }}
102-
- name: WORKFLOW_MANAGER_URL
103-
value: "{{ .Values.workflowManagerUrl }}"
104-
- name: WORKFLOW_MANAGER_ACCESS_KEY
105-
value: "{{ .Values.workflowManagerAccessKey }}"
106-
- name: WORKFLOW_MANAGER_SECRET_KEY
107-
value: "{{ .Values.workflowManagerSecretKey }}"
10897
{{- end }}
10998
- name: POD_IP
11099
valueFrom:

charts/controller/templates/controller-secret-creds.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,5 @@ data:
3131
registry-username: {{ .Values.registryUsername | b64enc }}
3232
registry-password: {{ .Values.registryPassword | b64enc }}
3333
{{- end }}
34-
service-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "service-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
3534
django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "django-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
3635
deploy-hook-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "deploy-hook-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}

rootfs/api/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def custom_exception_handler(exc, context):
4242
# No response means DRF couldn't handle it
4343
# Output a generic 500 in a JSON format
4444
if response is None:
45+
import traceback
46+
traceback.print_exc()
4547
logging.exception('Uncaught Exception', exc_info=exc)
4648
set_rollback()
4749
return Response({'detail': 'Server Error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

rootfs/api/manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ class ManagerAPI(object):
1515
def __init__(self, timeout=3):
1616
self.timeout = timeout
1717
token = base64.b85encode(b"%s:%s" % (
18-
settings.WORKFLOW_MANAGER_ACCESS_KEY.encode("utf8"),
19-
settings.WORKFLOW_MANAGER_SECRET_KEY.encode("utf8"),
18+
settings.SOCIAL_AUTH_DRYCC_KEY.encode("utf8"),
19+
settings.SOCIAL_AUTH_DRYCC_SECRET.encode("utf8"),
2020
)).decode("utf8")
2121
self.headers = {
2222
'Content-Type': 'application/json',

rootfs/api/permissions.py

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
import base64
2-
from rest_framework import permissions
1+
import requests
2+
import logging
3+
import urllib.parse
34
from django.conf import settings
5+
from django.core.cache import cache
6+
from rest_framework import permissions
7+
48
from api import manager
59
from api.models import blocklist
610
from api.models.workspace import Workspace, WorkspaceMember
711

12+
logger = logging.getLogger(__name__)
13+
814

915
def get_app_status(app):
1016
block = blocklist.Blocklist.get_blocklist(app)
@@ -41,8 +47,9 @@ def has_object_permission(self, request, view, obj):
4147
return True
4248
elif getattr(obj, "user", None) == request.user:
4349
return True
44-
elif isinstance(obj, Workspace) or hasattr(obj, 'workspace'):
45-
workspace = obj if isinstance(obj, Workspace) else obj.workspace
50+
elif isinstance(obj, Workspace) or hasattr(obj, 'workspace') or hasattr(obj, 'app'):
51+
workspace = obj if isinstance(obj, Workspace) else getattr(
52+
obj, "workspace", None) or getattr(getattr(obj, 'app', None), 'workspace', None)
4653
if request.method in ["GET", "HEAD", "OPTIONS"]:
4754
allowed_roles = ["viewer", "member", "admin"]
4855
elif request.method in ["POST", "PUT", "PATCH"]:
@@ -55,34 +62,43 @@ def has_object_permission(self, request, view, obj):
5562
return False
5663

5764

58-
class IsServiceToken(permissions.BasePermission):
65+
class HasOAuthScope(permissions.BasePermission):
5966
"""
60-
The service token is used for internal communication between Drycc components,
61-
such as the builder and Quickwit.
67+
Object-level permission to allow only requests with specific OAuth scopes.
68+
The required scopes are defined on the view as `required_oauth_scopes = ['scope1', 'scope2']`
6269
"""
63-
6470
def has_permission(self, request, view):
65-
"""
66-
Return `True` if permission is granted, `False` otherwise.
67-
"""
68-
auth_header = request.META.get('HTTP_X_DRYCC_SERVICE_KEY')
69-
if not auth_header:
70-
return False
71-
return auth_header == settings.SERVICE_KEY
71+
required_oauth_scopes = getattr(view, 'required_oauth_scopes', [])
72+
if not required_oauth_scopes:
73+
return True
7274

75+
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
76+
parts = auth_header.split()
77+
if len(parts) == 2 and parts[0].lower() == 'bearer':
78+
token = parts[1]
79+
else:
80+
return False
7381

74-
class IsWorkflowManager(permissions.BasePermission):
75-
"""
76-
View permission to allow workflow manager to perform actions
77-
with a special HTTP header
78-
"""
82+
scopes = self.get_token_scopes(token)
83+
return set(required_oauth_scopes).issubset(scopes)
7984

80-
def has_permission(self, request, view):
81-
if request.META.get("HTTP_AUTHORIZATION"):
82-
token = request.META.get(
83-
"HTTP_AUTHORIZATION").split(" ")[1].encode("utf8")
84-
access_key, secret_key = base64.b85decode(token).decode("utf8").split(":")
85-
if settings.WORKFLOW_MANAGER_ACCESS_KEY == access_key:
86-
if settings.WORKFLOW_MANAGER_SECRET_KEY == secret_key:
87-
return True
88-
return False
85+
def get_token_scopes(self, token):
86+
def _get_scopes():
87+
endpoint = getattr(settings, 'SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT', None)
88+
if not endpoint:
89+
return set()
90+
oauth_introspect_url = urllib.parse.urljoin(endpoint + "/", "introspect/")
91+
key = getattr(settings, 'SOCIAL_AUTH_DRYCC_KEY', '')
92+
secret = getattr(settings, 'SOCIAL_AUTH_DRYCC_SECRET', '')
93+
try:
94+
resp = requests.post(
95+
oauth_introspect_url, auth=(key, secret), data={'token': token}, timeout=5)
96+
if resp.status_code == 200:
97+
data = resp.json()
98+
if data.get("active"):
99+
return set(data.get("scope", "").split())
100+
except Exception as e:
101+
logger.info(f"Error introspecting token: {e}")
102+
return set()
103+
return cache.get_or_set(
104+
f"drycc_oauth_scopes_v2_{token}", _get_scopes, settings.DRYCC_CACHE_USER_TIME)

rootfs/api/serializers/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,3 +824,9 @@ class Meta:
824824
model = models.workspace.WorkspaceInvitation
825825
fields = '__all__'
826826
read_only_fields = ['id', 'token', 'created']
827+
828+
829+
class BlocklistSerializer(serializers.ModelSerializer):
830+
class Meta:
831+
model = models.blocklist.Blocklist
832+
fields = '__all__'

rootfs/api/settings/production.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,6 @@ def randstr(k):
328328
SECRET_KEY = os.environ.get('DRYCC_SECRET_KEY', randstr(64))
329329

330330
# Drycc service key
331-
SERVICE_KEY = os.environ.get('DRYCC_SERVICE_KEY', randstr(64))
332331

333332
# Drycc cert key
334333
CERT_KEY_PATH = os.environ.get('DRYCC_CERT_KEY_PATH', '/etc/controller/cert/key')
@@ -442,11 +441,11 @@ def randstr(k):
442441
# social auth settings
443442
SOCIAL_AUTH_DRYCC_KEY = os.environ.get(
444443
"DRYCC_PASSPORT_KEY",
445-
os.environ.get("SOCIAL_AUTH_DRYCC_KEY"),
444+
os.environ.get("SOCIAL_AUTH_DRYCC_KEY", ""),
446445
)
447446
SOCIAL_AUTH_DRYCC_SECRET = os.environ.get(
448447
'DRYCC_PASSPORT_SECRET',
449-
os.environ.get("SOCIAL_AUTH_DRYCC_SECRET"),
448+
os.environ.get("SOCIAL_AUTH_DRYCC_SECRET", ""),
450449
)
451450
SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT = os.environ.get(
452451
'SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT',
@@ -485,5 +484,3 @@ def randstr(k):
485484

486485
# Workflow-manager Configuration Options
487486
WORKFLOW_MANAGER_URL = os.environ.get('WORKFLOW_MANAGER_URL', None)
488-
WORKFLOW_MANAGER_ACCESS_KEY = os.environ.get('WORKFLOW_MANAGER_ACCESS_KEY', None)
489-
WORKFLOW_MANAGER_SECRET_KEY = os.environ.get('WORKFLOW_MANAGER_SECRET_KEY', None)

rootfs/api/settings/testing.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ def __getitem__(self, item):
7070
MIGRATION_MODULES = DisableMigrations()
7171

7272
# WORKFLOW_MANAGER_URL = "http://127.0.0.1:8000"
73-
WORKFLOW_MANAGER_ACCESS_KEY = "1234567890"
74-
WORKFLOW_MANAGER_SECRET_KEY = "1234567890"
7573

7674
DRYCC_VOLUME_CLAIM_TEMPLATE = """
7775
{% if type == 'csi' %}

rootfs/api/tests/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import requests_mock
44
import time
55
import unittest
6+
from unittest import mock
67
from os.path import dirname, realpath
78

89
from django.test.runner import DiscoverRunner
@@ -38,10 +39,25 @@ def fake_responses(request, context):
3839
return response['text']
3940

4041

42+
def mock_get_token_scopes(self, token):
43+
return {"controller:hook", "controller:blocklist", "controller:logs", "controller:metrics"}
44+
45+
46+
patcher = mock.patch('api.permissions.HasOAuthScope.get_token_scopes', mock_get_token_scopes)
47+
patcher.start()
48+
4149
adapter = requests_mock.Adapter()
4250
adapter.register_uri('GET', '/', text=fake_responses)
4351
adapter.register_uri('GET', '/health', text=fake_responses)
4452
adapter.register_uri('GET', '/healthz', text=fake_responses)
53+
adapter.register_uri(
54+
'POST',
55+
requests_mock.ANY,
56+
json={
57+
"active": True,
58+
"scope": "controller:hook controller:metrics controller:logs controller:blocklist"
59+
}
60+
)
4561

4662
# Root of the test directory (for files and such)
4763
TEST_ROOT = dirname(realpath(__file__))
Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
55
Run the tests with "./manage.py test api"
66
"""
7-
import base64
87
from django.core.cache import cache
9-
from django.conf import settings
108
from django.contrib.auth import get_user_model
119
from api.tests import adapter, DryccTransactionTestCase
1210
import requests_mock
@@ -15,43 +13,58 @@
1513

1614

1715
@requests_mock.Mocker(real_http=True, adapter=adapter)
18-
class ManagerTest(DryccTransactionTestCase):
16+
class BlocklistTest(DryccTransactionTestCase):
1917
"""Tests setting and updating config values"""
2018

2119
fixtures = ['tests.json']
2220

2321
def setUp(self):
22+
from unittest.mock import patch
23+
self.patcher = patch('api.apps_extra.social_core.backends.OauthCacheManager.get_user')
24+
self.mock_get_user = self.patcher.start()
25+
self.mock_get_user.return_value = User.objects.get(username='autotest')
2426

2527
self.user = User.objects.get(username='autotest')
2628
self.token = self.get_or_create_token(self.user)
2729
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
2830
self.app_id = self.create_app()
2931
self.user_id = 7
3032
# workflow manager token
31-
token = base64.b85encode(b"%s:%s" % (
32-
settings.WORKFLOW_MANAGER_ACCESS_KEY.encode("utf8"),
33-
settings.WORKFLOW_MANAGER_SECRET_KEY.encode("utf8"),
34-
)).decode("utf8")
35-
self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
33+
self.client.credentials(HTTP_AUTHORIZATION='Bearer mock_oauth_token')
3634

3735
def tearDown(self):
36+
self.patcher.stop()
37+
3838
# make sure every test has a clean slate for k8s mocking
3939
cache.clear()
4040

4141
def test_block(self, mock_requests):
4242
response = self.client.post(
43-
'/v2/manager/{}/{}/block/'.format("apps", self.app_id),
44-
data={'remark': 'Arrears blockade'},
43+
'/v2/blocklists/',
44+
data={'id': self.app_id, 'type': 1, 'remark': 'Arrears blockade'},
4545
)
4646
self.assertEqual(response.status_code, 201)
4747

4848
def test_unblock(self, mock_requests):
4949
response = self.client.post(
50-
'/v2/manager/{}/{}/block/'.format("apps", self.app_id),
51-
data={'remark': 'Arrears blockade'},
50+
'/v2/blocklists/',
51+
data={'id': self.app_id, 'type': 1, 'remark': 'Arrears blockade'},
5252
)
5353
self.assertEqual(response.status_code, 201)
5454
response = self.client.delete(
55-
'/v2/manager/{}/{}/unblock/'.format("apps", self.app_id),
55+
'/v2/blocklists/app/{}/'.format(self.app_id),
5656
)
5757
self.assertEqual(response.status_code, 204)
58+
59+
def test_retrieve(self, mock_requests):
60+
response = self.client.post(
61+
'/v2/blocklists/',
62+
data={'id': self.app_id, 'type': 1, 'remark': 'Arrears blockade'},
63+
)
64+
self.assertEqual(response.status_code, 201)
65+
response = self.client.get(
66+
'/v2/blocklists/app/{}/'.format(self.app_id),
67+
)
68+
self.assertEqual(response.status_code, 200)
69+
self.assertEqual(response.data['id'], self.app_id)
70+
self.assertEqual(response.data['type'], 1)

0 commit comments

Comments
 (0)