Skip to content

Commit 09ef36a

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

20 files changed

Lines changed: 573 additions & 281 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ develop-eggs
1414
.venv
1515
lib
1616
lib64
17+
.sisyphus
1718

1819
# Drycc' config file
1920
controller/drycc/local_settings.py

charts/controller/templates/_helpers.tpl

Lines changed: 12 additions & 13 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:
@@ -129,7 +118,7 @@ env:
129118
value: "http://drycc-victoriametrics-vmselect:8481/select/multitenant/prometheus"
130119
{{- end }}
131120
{{- if .Values.passport.enabled }}
132-
- name: "DRYCC_PASSPORT_URL"
121+
- name: DRYCC_PASSPORT_URL
133122
{{- if .Values.global.certManagerEnabled }}
134123
value: https://drycc-passport.{{ .Values.global.platformDomain }}
135124
{{- else }}
@@ -145,7 +134,12 @@ env:
145134
secretKeyRef:
146135
name: passport-creds
147136
key: drycc-passport-controller-secret
148-
{{- else }}
137+
- name: DRYCC_PASSPORT_SCOPES
138+
valueFrom:
139+
secretKeyRef:
140+
name: passport-creds
141+
key: drycc-passport-controller-scopes
142+
{{- else if .Values.passportUrl }}
149143
- name: DRYCC_PASSPORT_URL
150144
valueFrom:
151145
secretKeyRef:
@@ -161,6 +155,11 @@ env:
161155
secretKeyRef:
162156
name: controller-creds
163157
key: passport-secret
158+
- name: DRYCC_PASSPORT_SCOPES
159+
valueFrom:
160+
secretKeyRef:
161+
name: controller-creds
162+
key: passport-scopes
164163
{{- end }}
165164
- name: QUICKWIT_INDEXER_URL
166165
value: http://drycc-quickwit-indexer:7280

charts/controller/templates/controller-job-upgrade.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ metadata:
44
name: drycc-controller-job-upgrade
55
annotations:
66
component.drycc.cc/version: {{ .Values.imageTag }}
7-
helm.sh/hook: post-install,post-upgrade,post-rollback
8-
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
97
spec:
108
template:
119
spec:

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ data:
2626
{{- if (.Values.passportSecret) }}
2727
passport-secret: {{ .Values.passportSecret | b64enc }}
2828
{{- end }}
29+
{{- if (.Values.passportScopes) }}
30+
passport-scopes: {{ .Values.passportScopes | b64enc }}
31+
{{- end }}
2932
{{- if (.Values.registryHost) }}
3033
registry-host: {{ .Values.registryHost | b64enc }}
3134
registry-username: {{ .Values.registryUsername | b64enc }}
3235
registry-password: {{ .Values.registryPassword | b64enc }}
3336
{{- end }}
34-
service-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "service-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
3537
django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "django-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
3638
deploy-hook-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "deploy-hook-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22
import logging
33
import requests
4+
import urllib.parse
45
from typing import List, Dict
56
from django.core.cache import cache
67
from requests_toolbelt import user_agent
@@ -15,8 +16,8 @@ class ManagerAPI(object):
1516
def __init__(self, timeout=3):
1617
self.timeout = timeout
1718
token = base64.b85encode(b"%s:%s" % (
18-
settings.WORKFLOW_MANAGER_ACCESS_KEY.encode("utf8"),
19-
settings.WORKFLOW_MANAGER_SECRET_KEY.encode("utf8"),
19+
settings.SOCIAL_AUTH_DRYCC_KEY.encode("utf8"),
20+
settings.SOCIAL_AUTH_DRYCC_SECRET.encode("utf8"),
2021
)).decode("utf8")
2122
self.headers = {
2223
'Content-Type': 'application/json',
@@ -100,3 +101,72 @@ def post(self, usages: List[Dict[str, str]]):
100101
"""
101102
url = "%s/usages/" % settings.WORKFLOW_MANAGER_URL
102103
return super().post(url=url, json=usages)
104+
105+
106+
class PassportAPI(object):
107+
"""Service-to-service client for Drycc Passport using OAuth2 client_credentials."""
108+
109+
TOKEN_CACHE_KEY = "controller:passport:m2m_token"
110+
TOKEN_REFRESH_LEEWAY = 60
111+
112+
def __init__(self, timeout=10):
113+
self.timeout = timeout
114+
self.base_url = settings.DRYCC_PASSPORT_URL.rstrip("/")
115+
116+
def _get_token(self) -> str:
117+
token = cache.get(self.TOKEN_CACHE_KEY)
118+
if token and self.get_scopes(token) == set(settings.DRYCC_PASSPORT_SCOPES.split()):
119+
return token
120+
resp = requests.post(
121+
f"{self.base_url}/oauth/token/",
122+
data={
123+
"grant_type": "client_credentials",
124+
"client_id": settings.SOCIAL_AUTH_DRYCC_KEY,
125+
"client_secret": settings.SOCIAL_AUTH_DRYCC_SECRET,
126+
"scope": settings.DRYCC_PASSPORT_SCOPES,
127+
},
128+
timeout=self.timeout,
129+
)
130+
resp.raise_for_status()
131+
body = resp.json()
132+
token = body["access_token"]
133+
ttl = max(int(body.get("expires_in", 3600)) - self.TOKEN_REFRESH_LEEWAY, 60)
134+
cache.set(self.TOKEN_CACHE_KEY, token, timeout=ttl)
135+
return token
136+
137+
def get_scopes(self, token):
138+
def _get_scopes():
139+
endpoint = getattr(settings, 'SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT', None)
140+
if not endpoint:
141+
return set()
142+
oauth_introspect_url = urllib.parse.urljoin(endpoint + "/", "introspect/")
143+
key = getattr(settings, 'SOCIAL_AUTH_DRYCC_KEY', '')
144+
secret = getattr(settings, 'SOCIAL_AUTH_DRYCC_SECRET', '')
145+
try:
146+
resp = requests.post(
147+
oauth_introspect_url, auth=(key, secret), data={'token': token}, timeout=5)
148+
if resp.status_code == 200:
149+
data = resp.json()
150+
if data.get("active"):
151+
return set(data.get("scope", "").split())
152+
except Exception as e:
153+
logger.info(f"Error introspecting token: {e}")
154+
return set()
155+
return cache.get_or_set(
156+
f"drycc_oauth_scopes_v2_{token}", _get_scopes, settings.DRYCC_CACHE_USER_TIME)
157+
158+
def send_message(self, username: str, message: Dict) -> None:
159+
token = self._get_token()
160+
headers = {
161+
"Authorization": f"Bearer {token}",
162+
"Content-Type": "application/json",
163+
"User-Agent": user_agent("Drycc Controller", drycc_version),
164+
}
165+
body = {**message, "username": username}
166+
resp = requests.post(
167+
f"{self.base_url}/messages/",
168+
json=body,
169+
headers=headers,
170+
timeout=self.timeout,
171+
)
172+
resp.raise_for_status()

rootfs/api/permissions.py

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import base64
2-
from rest_framework import permissions
1+
import logging
32
from django.conf import settings
4-
from api import manager
3+
from rest_framework import permissions
4+
5+
from api import clients
56
from api.models import blocklist
67
from api.models.workspace import Workspace, WorkspaceMember
78

9+
logger = logging.getLogger(__name__)
10+
811

912
def get_app_status(app):
1013
block = blocklist.Blocklist.get_blocklist(app)
1114
if block:
1215
return False, block.remark
1316
if settings.WORKFLOW_MANAGER_URL:
14-
status = manager.WorkspaceAPI().get_status(app.workspace_id)
17+
status = clients.WorkspaceAPI().get_status(app.workspace_id)
1518
if not status["is_active"]:
1619
return False, status["message"]
1720
return True, None
@@ -41,8 +44,9 @@ def has_object_permission(self, request, view, obj):
4144
return True
4245
elif getattr(obj, "user", None) == request.user:
4346
return True
44-
elif isinstance(obj, Workspace) or hasattr(obj, 'workspace'):
45-
workspace = obj if isinstance(obj, Workspace) else obj.workspace
47+
elif isinstance(obj, Workspace) or hasattr(obj, 'workspace') or hasattr(obj, 'app'):
48+
workspace = obj if isinstance(obj, Workspace) else getattr(
49+
obj, "workspace", None) or getattr(getattr(obj, 'app', None), 'workspace', None)
4650
if request.method in ["GET", "HEAD", "OPTIONS"]:
4751
allowed_roles = ["viewer", "member", "admin"]
4852
elif request.method in ["POST", "PUT", "PATCH"]:
@@ -55,34 +59,23 @@ def has_object_permission(self, request, view, obj):
5559
return False
5660

5761

58-
class IsServiceToken(permissions.BasePermission):
62+
class HasOAuthScope(permissions.BasePermission):
5963
"""
60-
The service token is used for internal communication between Drycc components,
61-
such as the builder and Quickwit.
64+
Object-level permission to allow only requests with specific OAuth scopes.
65+
The required scopes are defined on the view as `required_oauth_scopes = ['scope1', 'scope2']`
6266
"""
67+
client = clients.PassportAPI()
6368

6469
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
72-
73-
74-
class IsWorkflowManager(permissions.BasePermission):
75-
"""
76-
View permission to allow workflow manager to perform actions
77-
with a special HTTP header
78-
"""
70+
required_oauth_scopes = getattr(view, 'required_oauth_scopes', [])
71+
if not required_oauth_scopes:
72+
return True
7973

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
74+
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
75+
parts = auth_header.split()
76+
if len(parts) == 2 and parts[0].lower() == 'bearer':
77+
token = parts[1]
78+
else:
79+
return False
80+
scopes = self.client.get_scopes(token)
81+
return set(required_oauth_scopes).issubset(scopes)

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/celery.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ class Config(object):
5858
'queue': 'controller.middle',
5959
'exchange': 'controller.priority', 'routing_key': 'controller.priority.middle',
6060
},
61+
'api.tasks.dispatch_alert_message': {
62+
'queue': 'controller.middle',
63+
'exchange': 'controller.priority', 'routing_key': 'controller.priority.middle',
64+
},
6165
},
6266
task_queues=(
6367
Queue(

rootfs/api/settings/production.py

Lines changed: 6 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',
@@ -466,6 +465,10 @@ def randstr(k):
466465
)
467466
DRYCC_CACHE_USER_TIME = int(os.environ.get('DRYCC_CACHE_USER_TIME', 30 * 60))
468467

468+
# OIDC scopes to request from the provider.
469+
# The provider must be configured to allow these scopes for the Grafana client.
470+
DRYCC_PASSPORT_SCOPES = os.environ.get('DRYCC_PASSPORT_SCOPES', '')
471+
469472
# Rate limit for invitation emails: max LIMIT emails per WINDOW seconds per recipient address
470473
DRYCC_INVITATION_EMAIL_LIMIT = int(os.environ.get('DRYCC_INVITATION_EMAIL_LIMIT', 5))
471474
DRYCC_INVITATION_EMAIL_TIMEOUT = int(os.environ.get('DRYCC_INVITATION_EMAIL_TIMEOUT', 3600))
@@ -485,5 +488,3 @@ def randstr(k):
485488

486489
# Workflow-manager Configuration Options
487490
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' %}

0 commit comments

Comments
 (0)