Skip to content

Commit 8854551

Browse files
committed
feat(oauth): use oauth to unify service-to-service authentication.
1 parent df1ae42 commit 8854551

13 files changed

Lines changed: 199 additions & 19 deletions

File tree

charts/passport/values.yaml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,34 @@ initApplications:
118118
key: ""
119119
secret: ""
120120
prefix: "drycc"
121-
grant_type: "password"
121+
grant_type: "internal"
122+
client_type: "confidential"
123+
allowed_scopes: "openid profile email manager:workspace manager:usage passport:message"
122124
redirect_uri: "/v2/complete/drycc/"
123125
- name: "grafana"
124126
key: ""
125127
secret: ""
126128
prefix: "drycc-grafana"
127-
grant_type: "authorization-code"
129+
grant_type: "internal"
130+
client_type: "confidential"
131+
allowed_scopes: "openid profile email controller:metrics controller:logs"
128132
redirect_uri: "/oauth2/callback"
133+
- name: "builder"
134+
key: ""
135+
secret: ""
136+
prefix: ""
137+
grant_type: "client-credentials"
138+
client_type: "confidential"
139+
allowed_scopes: "controller:hook"
140+
redirect_uri: ""
141+
- name: "manager"
142+
key: ""
143+
secret: ""
144+
prefix: ""
145+
grant_type: "client-credentials"
146+
client_type: "confidential"
147+
allowed_scopes: "controller:blocklist"
148+
redirect_uri: ""
129149

130150
# Service
131151
service:

rootfs/api/admin.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
from django.contrib import admin
22
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
3+
from oauth2_provider.admin import ApplicationAdmin as BaseApplicationAdmin
34

4-
from .models import User, Message, MessagePreference
5+
from .models import User, Message, MessagePreference, Application
6+
7+
8+
try:
9+
admin.site.unregister(Application)
10+
except admin.sites.NotRegistered:
11+
pass
12+
13+
14+
@admin.register(Application)
15+
class ApplicationAdmin(BaseApplicationAdmin):
16+
list_display = (
17+
"id", "name", "user", "client_type",
18+
"authorization_grant_type", "allowed_scopes",
19+
)
520

621

722
class UserAdmin(BaseUserAdmin):

rootfs/api/management/commands/create_oauth2_application.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ def handle(self, *args, **options):
3535
'client_secret': self._get_creds(item, "secret", 60),
3636
'user': user,
3737
'redirect_uris': self._get_redirect_uri(item),
38-
'authorization_grant_type': item['grant_type'],
39-
'client_type': 'public',
38+
'authorization_grant_type': item.get('grant_type', 'public'),
39+
'client_type': item.get('client_type', 'public'),
40+
'allowed_scopes': item.get('allowed_scopes', ''),
4041
'algorithm': 'RS256'
4142
}
4243
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 5.2.13 on 2026-05-09 12:00
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
def forward_data_migration(apps, schema_editor):
8+
Application = apps.get_model('api', 'Application')
9+
oauth2_settings = getattr(settings, 'OAUTH2_PROVIDER', {})
10+
default_provider_scopes_list = oauth2_settings.get("DEFAULT_SCOPES", ['openid', 'email', 'profile'])
11+
default_provider_scopes = " ".join(default_provider_scopes_list)
12+
13+
for app in Application.objects.all():
14+
if not app.allowed_scopes:
15+
app.allowed_scopes = default_provider_scopes
16+
17+
if app.name.lower() in ("controller", "grafana"):
18+
app.authorization_grant_type = "internal"
19+
20+
app.save()
21+
22+
class Migration(migrations.Migration):
23+
24+
dependencies = [
25+
('api', '0004_alter_application_authorization_grant_type_message_and_more'),
26+
]
27+
28+
operations = [
29+
migrations.AddField(
30+
model_name='application',
31+
name='allowed_scopes',
32+
field=models.TextField(blank=True, default=''),
33+
),
34+
migrations.RunPython(forward_data_migration, reverse_code=migrations.RunPython.noop),
35+
]

rootfs/api/models.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ class User(AbstractUser):
1111

1212

1313
class Application(AbstractApplication):
14+
GRANT_INTERNAL = "internal"
15+
GRANT_TYPES = AbstractApplication.GRANT_TYPES + (
16+
(GRANT_INTERNAL, _("Internal")),
17+
)
18+
allowed_scopes = models.TextField(blank=True, default="")
1419

1520
def allows_grant_type(self, *grant_types):
16-
return self.GRANT_AUTHORIZATION_CODE in grant_types or super().allows_grant_type(
17-
*grant_types
18-
)
21+
if self.authorization_grant_type == self.GRANT_INTERNAL:
22+
return True
23+
return super().allows_grant_type(*grant_types)
1924

2025

2126
class Message(models.Model):

rootfs/api/oauth2_validators.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33

44
class CustomOAuth2Validator(OAuth2Validator):
55

6+
def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
7+
if client.allowed_scopes:
8+
allowed = set(client.allowed_scopes.split())
9+
if not set(scopes).issubset(allowed):
10+
return False
11+
return super().validate_scopes(client_id, scopes, client, request, *args, **kwargs)
12+
613
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
714
oidc_claim_scope.update({
815
"id": "profile",

rootfs/api/permissions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.utils import timezone
2+
from rest_framework import permissions
3+
4+
from oauth2_provider.models import AccessToken
5+
6+
7+
class HasOAuthScope(permissions.BasePermission):
8+
"""
9+
Object-level permission to allow only requests with specific OAuth scopes.
10+
The required scopes are defined on the view as `required_oauth_scopes = ['scope1', 'scope2']`
11+
"""
12+
def has_permission(self, request, view):
13+
required_oauth_scopes = getattr(view, 'required_oauth_scopes', [])
14+
if not required_oauth_scopes:
15+
return True
16+
17+
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
18+
parts = auth_header.split()
19+
if len(parts) == 2 and parts[0].lower() == 'bearer':
20+
token_string = parts[1]
21+
else:
22+
return False
23+
24+
scopes = self.get_token_scopes(token_string)
25+
return set(required_oauth_scopes).issubset(scopes)
26+
27+
def get_token_scopes(self, token_string):
28+
try:
29+
access_token = AccessToken.objects.get(token=token_string, expires__gt=timezone.now())
30+
return set(access_token.scope.split())
31+
except AccessToken.DoesNotExist:
32+
return set()

rootfs/api/serializers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,21 @@ class Meta:
9898
fields = ['email_alerts', 'push_alerts', 'webhook_url',
9999
'notify_security', 'notify_system', 'notify_product',
100100
'notify_alert', 'notify_service']
101+
102+
103+
class ServiceMessageSerializer(MessageSerializer):
104+
username = serializers.CharField(write_only=True)
105+
106+
class Meta(MessageSerializer.Meta):
107+
fields = MessageSerializer.Meta.fields + ['username']
108+
109+
def create(self, validated_data):
110+
from django.contrib.auth import get_user_model
111+
User = get_user_model()
112+
username = validated_data.pop('username')
113+
try:
114+
user = User.objects.get(username=username)
115+
except User.DoesNotExist:
116+
raise serializers.ValidationError({"username": "User not found."})
117+
validated_data['user'] = user
118+
return super().create(validated_data)

rootfs/api/settings/production.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,13 @@
329329
"email": "Email",
330330
"profile": "Profile",
331331
"openid": "OpenID Connect scope",
332+
"controller:hook": "Controller Hook",
333+
"controller:blocklist": "Controller Blocklist",
334+
"controller:logs": "Controller Logs",
335+
"controller:metrics": "Controller Metrics",
336+
"manager:workspace": "Manager Workspace",
337+
"manager:usage": "Manager Usage",
338+
"passport:message": "Passport Message",
332339
},
333340
"DEFAULT_SCOPES": ['openid', 'email', 'profile'],
334341
"DEFAULT_CODE_CHALLENGE_METHOD": 'S256',

rootfs/api/tests/test_views.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,40 @@ def test_list_messages_supports_limit_offset_pagination(self):
492492
self.assertEqual(len(response.data['results']), 1)
493493
self.assertEqual(response.data['count'], 2)
494494

495-
def test_create_message_for_current_user(self):
496-
response = self.client.post(reverse('user_messages-list'), {
495+
def test_service_message_create(self):
496+
from api.models import Application
497+
from urllib.parse import urlencode
498+
499+
# Setup application
500+
Application.objects.create(
501+
name='test_app',
502+
client_type='confidential',
503+
client_id='my_test_client_id',
504+
client_secret='my_test_client_secret',
505+
authorization_grant_type='client-credentials',
506+
user=self.user
507+
)
508+
509+
# 1. Fetch access token via client-credentials flow
510+
data = urlencode({
511+
'grant_type': 'client_credentials',
512+
'client_id': 'my_test_client_id',
513+
'client_secret': 'my_test_client_secret',
514+
'scope': 'passport:message'
515+
})
516+
517+
token_response = self.client.post(
518+
'/oauth/token/',
519+
data=data,
520+
content_type='application/x-www-form-urlencoded'
521+
)
522+
523+
self.assertEqual(token_response.status_code, 200, token_response.json())
524+
access_token = token_response.json()['access_token']
525+
526+
# 2. Use the access token to create a message
527+
response = self.client.post('/messages/', {
528+
'username': self.user.username,
497529
'category': 'alert',
498530
'title': 'Created from API',
499531
'content': 'API created content',
@@ -502,12 +534,10 @@ def test_create_message_for_current_user(self):
502534
'is_read': False,
503535
'action_link': '/messages',
504536
'action_text': 'Open',
505-
})
537+
}, HTTP_AUTHORIZATION=f'Bearer {access_token}')
506538

507-
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
508-
created = Message.objects.get(id=response.data['id'])
509-
self.assertEqual(created.user, self.user)
510-
self.assertEqual(created.category, 'alert')
539+
self.assertEqual(response.status_code, 201)
540+
self.assertTrue(Message.objects.filter(user=self.user, title='Created from API').exists())
511541

512542
def test_mark_all_messages_as_read(self):
513543
response = self.client.put(reverse('user_messages-mark-all-read'))

0 commit comments

Comments
 (0)