Skip to content

Commit 2107f08

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

20 files changed

Lines changed: 432 additions & 62 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ htmlcov/
2727
venv/
2828

2929
.idea
30+
.sisyphus
3031
env/
3132
rootfs/web/yarn-error.log
3233
rootfs/node_modules/

charts/passport/templates/passport-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-passport-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/passport/templates/passport-secret-creds.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@ data:
1717
django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" "django-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
1818
oidc-rsa-private-key: {{genPrivateKey "rsa" | b64enc}}
1919
{{- range $item := .Values.initApplications }}
20-
{{- if ($item.prefix) }}
2120
{{- $name := ($item.name | replace " " "-" | lower) }}
2221
drycc-passport-{{$name}}-key: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" (printf "drycc-passport-%s-key" $name) "defaultValue" ($item.key | default (randAlphaNum 40)) "context" $)) }}
2322
drycc-passport-{{$name}}-secret: {{ (include "common.secrets.lookup" (dict "secret" "passport-creds" "key" (printf "drycc-passport-%s-secret" $name) "defaultValue" ($item.secret | default (randAlphaNum 64)) "context" $)) }}
24-
{{- end }}
23+
drycc-passport-{{$name}}-scopes: {{ ($item.allowed_scopes | default "") | b64enc | quote }}
2524
{{- end }}

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:alerts 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: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import string
55
import pathlib
66
from django.contrib.auth import get_user_model
7+
from django.contrib.auth.hashers import check_password
78
from django.core.management.base import BaseCommand
89
from oauth2_provider.models import get_application_model
910

@@ -14,7 +15,26 @@
1415

1516

1617
class Command(BaseCommand):
17-
"""Management command for create Oauth2 application"""
18+
"""Management command for create Oauth2 application.
19+
20+
Credential resolution order for ``client_id`` / ``client_secret``:
21+
22+
1. Explicit value from the init-applications JSON file (highest priority,
23+
lets operators pin credentials via ``--set initApplications[...]``).
24+
2. Mounted Kubernetes secret file at
25+
``/var/run/secrets/drycc/passport/drycc-passport-<name>-<key|secret>``.
26+
This is the source of truth for credentials that the chart's
27+
``passport-creds`` Secret already exposes to every consumer (controller,
28+
grafana, builder, manager, ...). Reading it here keeps the DB and the
29+
Secret consistent for *every* application, including m2m apps that
30+
have no public sub-domain (``prefix == ""``).
31+
3. Newly generated random string (only when neither of the above is
32+
available, e.g. local dev without the volume mount).
33+
34+
Note: ``prefix`` only controls how ``redirect_uri`` is composed. It is
35+
intentionally NOT used to gate credential discovery -- m2m applications
36+
legitimately have an empty prefix.
37+
"""
1838

1939
def add_arguments(self, parser):
2040
super(Command, self).add_arguments(parser)
@@ -28,32 +48,55 @@ def handle(self, *args, **options):
2848
user = User.objects.filter(is_superuser=True).first()
2949
for item in json.loads(pathlib.Path(base_path).read_text()):
3050
name = item["name"]
31-
_, updated = Application.objects.update_or_create(
32-
name=name.lower(),
33-
defaults={
34-
'client_id': self._get_creds(item, "key", 40),
35-
'client_secret': self._get_creds(item, "secret", 60),
36-
'user': user,
37-
'redirect_uris': self._get_redirect_uri(item),
38-
'authorization_grant_type': item['grant_type'],
39-
'client_type': 'public',
40-
'algorithm': 'RS256'
41-
}
51+
client_id = self._get_creds(item, "key", 40)
52+
client_secret = self._get_creds(item, "secret", 60)
53+
defaults = {
54+
'client_id': client_id,
55+
'user': user,
56+
'redirect_uris': self._get_redirect_uri(item),
57+
'authorization_grant_type': item.get('grant_type', 'public'),
58+
'client_type': item.get('client_type', 'public'),
59+
'allowed_scopes': item.get('allowed_scopes', ''),
60+
'algorithm': 'RS256',
61+
}
62+
existing = Application.objects.filter(name=name.lower()).first()
63+
secret_unchanged = (
64+
existing is not None
65+
and check_password(client_secret, existing.client_secret)
66+
)
67+
if not secret_unchanged:
68+
defaults['client_secret'] = client_secret
69+
_, created = Application.objects.update_or_create(
70+
name=name.lower(), defaults=defaults,
4271
)
43-
if updated:
44-
self.stdout.write('Drycc % app created' % name)
72+
if created:
73+
self.stdout.write('Drycc %s app created' % name)
4574
else:
46-
self.stdout.write('Drycc % app updated' % name)
75+
self.stdout.write('Drycc %s app updated' % name)
4776

4877
def _get_creds(self, item, suffix, size):
49-
name, secret, prefix = item["name"], item[suffix], item["prefix"]
50-
if not secret:
51-
default_secret_path = os.path.join(
52-
secrets_path, "drycc-passport-%s-%s" % (name, suffix))
53-
if prefix and os.path.exists(default_secret_path):
54-
secret = pathlib.Path(default_secret_path).read_text()
55-
else:
56-
secret = ''.join([random.choice(string.ascii_letters) for _ in range(size)])
78+
name = item["name"]
79+
secret = item.get(suffix)
80+
if secret:
81+
self.stdout.write(
82+
'[%s/%s] credential source: init-config' % (name, suffix))
83+
return secret
84+
85+
default_secret_path = os.path.join(
86+
secrets_path, "drycc-passport-%s-%s" % (name, suffix))
87+
if os.path.exists(default_secret_path):
88+
# ``.strip()`` defends against trailing newlines that some
89+
# tooling adds when materialising secrets onto disk.
90+
secret = pathlib.Path(default_secret_path).read_text().strip()
91+
self.stdout.write(
92+
'[%s/%s] credential source: mounted-file (%s)'
93+
% (name, suffix, default_secret_path))
94+
return secret
95+
96+
secret = ''.join(random.choice(string.ascii_letters) for _ in range(size))
97+
self.stdout.write(
98+
'[%s/%s] credential source: generated-random '
99+
'(no init value, no mounted file)' % (name, suffix))
57100
return secret
58101

59102
def _get_redirect_uri(self, item):
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: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,21 @@ 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)
24+
25+
def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs):
26+
if self.authorization_grant_type == self.GRANT_INTERNAL:
27+
return True
28+
return super().validate_grant_type(client_id, grant_type, client, request, *args, **kwargs)
1929

2030

2131
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()

0 commit comments

Comments
 (0)